Add project files.

This commit is contained in:
Alan Moon 2026-07-23 13:54:06 -07:00
parent cfdca57c3c
commit 7c51dc217b
66 changed files with 4368 additions and 0 deletions

30
.dockerignore Normal file
View File

@ -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/**

21
.env.sample Normal file
View File

@ -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

63
.gitattributes vendored Normal file
View File

@ -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

372
.gitignore vendored Normal file
View File

@ -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

33
README.md Normal file
View File

@ -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.

16
docker-compose.dcproj Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" Sdk="Microsoft.Docker.Sdk">
<PropertyGroup Label="Globals">
<ProjectVersion>2.1</ProjectVersion>
<DockerTargetOS>Linux</DockerTargetOS>
<DockerPublishLocally>False</DockerPublishLocally>
<ProjectGuid>b1b3d2c9-92e6-4711-842e-c940a40a9a2e</ProjectGuid>
<DockerLaunchAction>LaunchBrowser</DockerLaunchAction>
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}/swagger</DockerServiceUrl>
<DockerServiceName>qtc-net-server</DockerServiceName>
</PropertyGroup>
<ItemGroup>
<None Include="docker-compose.yml" />
<None Include=".dockerignore" />
</ItemGroup>
</Project>

60
docker-compose.yml Normal file
View File

@ -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:

11
launchSettings.json Normal file
View File

@ -0,0 +1,11 @@
{
"profiles": {
"Docker Compose": {
"commandName": "DockerCompose",
"commandVersion": "1.0",
"serviceActions": {
"qtc-net-server": "StartDebugging"
}
}
}
}

31
qtc-net-server.sln Normal file
View File

@ -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

View File

@ -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<ChatHub> _chatGWContext;
private readonly IConfiguration _configuration;
private readonly ServerConfig? serverConfig;
private readonly DataContext dataContext;
public AuthController(IUserService userService, ITokenService tokenService, IHubContext<ChatHub> chatGWContext, DataContext dataContext, IConfiguration configuration, IEmailService emailService)
{
_userService = userService;
_tokenService = tokenService;
_chatGWContext = chatGWContext;
_configuration = configuration;
_emailService = emailService;
serverConfig = JsonSerializer.Deserialize<ServerConfig>(JsonDocument.Parse(System.IO.File.ReadAllText("./Resources/ServerConfig.json")));
this.dataContext = dataContext;
}
[HttpPost("register")]
public async Task<ActionResult<ServiceResponse<User>>> 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<ActionResult<ServiceResponse<string>>> Login(UserLoginDto request)
{
var dbUser = await _userService.GetUserByEmail(request.Email);
if (dbUser.Data == null)
{
return Ok(new ServiceResponse<string>
{
Message = "User not found.",
Success = false
});
}
else if (!BCrypt.Net.BCrypt.Verify(request.Password, dbUser.Data.PasswordHash))
{
return Ok(new ServiceResponse<string>
{
Message = "Incorrect password.",
Success = false
});
}
if (!dbUser.Data.IsEmailVerified && _configuration.GetValue<bool>("EmailConfig:EmailConfirmationRequired"))
{
return Ok(new ServiceResponse<string>
{
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<string> _tagsList = [.. dbUser.Data.Tags];
_tagsList.Remove("new_user");
dbUser.Data.Tags = [.. _tagsList];
}
await dataContext.SaveChangesAsync();
return Ok(token);
}
[HttpPost("refresh")]
public async Task<ActionResult<ServiceResponse<string>>> RefreshLogin(string token)
{
var response = await _tokenService.ValidateRefreshToken(token);
return Ok(response);
}
[HttpPost("resend-verification-email")]
public async Task<ActionResult<ServiceResponse<bool>>> 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<bool> { Success = true, Data = true });
}
return Ok(new ServiceResponse<bool> { Success = false });
}
[HttpPost("request-password-reset")]
[Authorize]
public async Task<ActionResult<ServiceResponse<bool>>> 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<bool> { Success = true, Data = true });
}
return Ok(new ServiceResponse<bool> { Success = false });
}
[HttpPost("reset-password")]
public async Task<ActionResult<ServiceResponse<bool>>> ResetPassword(UserPasswordResetDto request)
{
try
{
var handler = new JwtSecurityTokenHandler()
{
InboundClaimTypeMap = new Dictionary<string, string>()
};
var jwtValidationResult = await handler.ValidateTokenAsync(request.Token, _tokenService.GetValidationParams().Data);
if (!jwtValidationResult.IsValid) return Ok(new ServiceResponse<bool> { 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<bool> { 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<bool> { Success = true, Data = true });
}
}
else
{
return Ok(new ServiceResponse<bool> { Success = false, Message = "The User This Reset Token Is Associated With No Longer Exists." });
}
}
}
return Ok(new ServiceResponse<bool> { Success = false, Message = "Token Invalid. You may need to be sent another reset link." });
}
catch (SecurityTokenMalformedException)
{
return Ok(new ServiceResponse<bool> { Success = false, Message = "Token Invalid. You may need to be sent another reset link." });
}
}
[HttpGet("verify-email")]
public async Task<ActionResult<string>> VerifyEmail(string token)
{
try
{
var handler = new JwtSecurityTokenHandler()
{
InboundClaimTypeMap = new Dictionary<string, string>()
};
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<ActionResult<string>> StartPasswordReset(string token)
{
try
{
var handler = new JwtSecurityTokenHandler()
{
InboundClaimTypeMap = new Dictionary<string, string>()
};
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.");
}
}
}
}

View File

@ -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<ChatHub> _chatGwContext;
public ContactController(IContactService contactService, IHubContext<ChatHub> chatGwContext)
{
_contactService = contactService;
_chatGwContext = chatGwContext;
}
[HttpPost("add-contact")]
[Authorize]
public async Task<ActionResult<ServiceResponse<Contact>>> AddContact(string userId)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<ActionResult<ServiceResponse<bool>>> ApproveContact(string userId)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<ActionResult<ServiceResponse<Contact>>> RemoveContact(string userId)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<ActionResult<ServiceResponse<List<Contact>>>> GetUserContacts(User user)
{
var result = await _contactService.GetUserContacts(user);
return Ok(result);
}
}
}

View File

@ -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<ServiceResponse<int>> GetCurrentPricePerStock()
{
var price = _currencyGamesService.GetCurrentPricePerStock();
if (price != null && price.Success)
{
return Ok(new ServiceResponse<int>
{
Success = true,
Data = price.Data
});
}
else return Ok(new ServiceResponse<int>
{
Success = false,
Message = "Service Indicated Failiure"
});
}
[HttpPost("stock-market/buy-stock")]
[Authorize]
public async Task<ActionResult<ServiceResponse<UserStockActionResultDto>>> BuyStockFromMarket(int amount)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<UserStockActionResultDto> { Success = true, Data = stocks.Data });
}
else return Ok(new ServiceResponse<UserStockActionResultDto> { Success = false, Message = stocks.Message });
}
else return Ok(new ServiceResponse<UserStockActionResultDto> { Success = false, Message = "Identity User Not Found" });
}
else return Ok(new ServiceResponse<UserStockActionResultDto> { Success = false, Message = "No Identity" });
}
[HttpPost("stock-market/sell-stock")]
[Authorize]
public async Task<ActionResult<ServiceResponse<UserStockActionResultDto>>> SellStockToMarket(int amount)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<UserStockActionResultDto> { Success = true, Data = stocks.Data });
}
else return Ok(new ServiceResponse<UserStockActionResultDto> { Success = false, Message = stocks.Message });
}
else return Ok(new ServiceResponse<UserStockActionResultDto> { Success = false, Message = "Identity User Not Found" });
}
else return Ok(new ServiceResponse<UserStockActionResultDto> { Success = false, Message = "No Identity" });
}
// Number Guess
[HttpGet("number-guess/get-number")]
[Authorize]
public ActionResult<ServiceResponse<int>> GetRandomNumber()
{
return Ok(_currencyGamesService.GetRandomNumber());
}
[HttpGet("number-guess/guess-number")]
[Authorize]
public ActionResult<ServiceResponse<NumberGuessResult>> GuessNumber(int original, int guess)
{
return Ok(_currencyGamesService.GuessRandomNumber(original, guess));
}
}
}

View File

@ -0,0 +1,17 @@
namespace qtc_api.Controllers
{
[Route("api/general")]
[ApiController]
public class GeneralController : ControllerBase
{
private readonly ILogger<GeneralController> logger;
public GeneralController(ILogger<GeneralController> logger) => this.logger = logger;
[HttpGet("ping")]
public ActionResult PingAsync()
{
logger.LogInformation($"Ping Received From Client");
return Ok("Pong!");
}
}
}

View File

@ -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<ChatHub> _hubContext;
public RoomsController(IRoomService roomService, IHubContext<ChatHub> hubContext)
{
_roomService = roomService;
_hubContext = hubContext;
}
[HttpPost("create-room")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult<ServiceResponse<Room>>> 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<ActionResult<ServiceResponse<Room>>> 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<ActionResult<ServiceResponse<List<Room>>>> GetAllRooms()
{
var rooms = await _roomService.GetAllRooms();
return Ok(rooms);
}
}
}

View File

@ -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<ServiceResponse<List<StoreItem>>> GetAllItems()
{
return Ok(_storeService.GetStoreItems());
}
[HttpGet]
[Route("item")]
public ActionResult<ServiceResponse<StoreItem>> GetItem(int id)
{
return Ok(_storeService.GetStoreItem(id));
}
[HttpGet]
[Route("bought-items")]
public ActionResult<ServiceResponse<List<OwnedStoreItem>>> GetBoughtStoreItems()
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> claims = identity.Claims;
var userId = claims.First().Value;
if (userId != null)
{
var result = _storeService.GetBoughtStoreItemsFromUser(userId);
return Ok(result);
}
else return Ok(new ServiceResponse<List<OwnedStoreItem>> { Success = false, Message = "No UserId In Auth Header" });
}
else return Ok(new ServiceResponse<List<OwnedStoreItem>> { Success = false, Message = "No Auth Header" });
}
[HttpGet]
[Route("bought-item")]
public ActionResult<ServiceResponse<OwnedStoreItem>> GetBoughtItem(int id)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<List<OwnedStoreItem>> { Success = false, Message = "No UserId In Auth Header" });
}
else return Ok(new ServiceResponse<List<OwnedStoreItem>> { Success = false, Message = "No Auth Header" });
}
[HttpPost]
[Route("buy-item")]
[Authorize]
public async Task<ActionResult<ServiceResponse<bool>>> BuyStoreItem(int id)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<bool> { Success = false, Message = "No UserId In Auth Header" });
}
else return Ok(new ServiceResponse<bool> { Success = false, Message = "No Auth Header" });
}
}
}

View File

@ -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<ActionResult> 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)
}
}
}
}

View File

@ -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<ChatHub> _chatGWContext;
public UsersController(IUserService userService, IDistributedCache distributedCache, IHubContext<ChatHub> chatGWContext)
{
_userService = userService;
_cache = distributedCache;
_chatGWContext = chatGWContext;
}
[HttpGet("all")]
[Authorize]
public async Task<ActionResult<ServiceResponse<List<UserInformationDto>>>> GetAllUsers()
{
var users = await _userService.GetAllUsers();
return Ok(users);
}
[HttpGet("user-info")]
[Authorize]
public async Task<ActionResult<ServiceResponse<UserInformationDto>>> GetUserInformation(string id)
{
var user = await _userService.GetUserInformationById(id);
return Ok(user);
}
[HttpGet("user-authorized")]
[Authorize]
public async Task<ActionResult<ServiceResponse<User>>> UserFromAuthorizeHead()
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<ActionResult<ServiceResponse<UserInformationDto>>> UpdateUserInformation(UserUpdateInformationDto user)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<ActionResult<ServiceResponse<string>>> UploadOrUpdateProfilePic(string userId, IFormFile file)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<ActionResult> GetUserProfilePicture(string userId)
{
string recordId = $"UserPfp_{userId}";
byte[]? pfpBytes = await _cache.GetImageAsync(recordId);
var result = new ServiceResponse<FileContentResult>();
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<ActionResult<ServiceResponse<User>>> 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<ActionResult<ServiceResponse<int>>> UpdateUserCurrency(int amount, bool isSpinClaim)
{
var identity = HttpContext.User.Identity as ClaimsIdentity;
if (identity != null)
{
IEnumerable<Claim> 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<int> { Success = false, Message = "Identity Has No User ID" });
}
else return Ok(new ServiceResponse<int> { Success = false, Message = "No Identity" });
}
}
}

View File

@ -0,0 +1,75 @@
namespace qtc_api.Data
{
public class DataContext : DbContext
{
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public DbSet<User> Users { get; set; }
public DbSet<Room> Rooms { get; set; }
public DbSet<RefreshToken> ValidRefreshTokens { get; set; }
public DbSet<Contact> Contacts { get; set; }
public DbSet<OwnedStoreItem> 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<User>().HasMany(e => e.ContactsList);
builder.Entity<User>().HasMany(e => e.ContactsMade);
builder.Entity<User>().HasMany(e => e.OwnedStoreItems);
builder.Entity<User>()
.Property<string>("_tagString")
.HasColumnName("Tags");
// Rooms (no relations)
builder.Entity<Room>();
// Refresh Tokens
builder.Entity<RefreshToken>().HasOne(e => e.User)
.WithMany(e => e.RefreshTokens)
.HasForeignKey(e => e.UserID);
// Contacts
builder.Entity<Contact>().HasOne(e => e.Owner)
.WithMany(e => e.ContactsMade)
.HasForeignKey(e => e.OwnerId);
builder.Entity<Contact>().HasOne(e => e.User)
.WithMany(e => e.ContactsList)
.HasForeignKey(e => e.UserId);
// Purchased Store Items
builder.Entity<OwnedStoreItem>().HasOne(e => e.User)
.WithMany(e => e.OwnedStoreItems)
.HasForeignKey(e => e.UserId);
}
}
}

21
qtc-net-server/Dockerfile Normal file
View File

@ -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"]

View File

@ -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();
}
}

View File

@ -0,0 +1,8 @@
namespace qtc_api.Dtos.User
{
public class UserConnectionDto
{
public Models.User? ConnectedUser { get; set; }
public string? ConnectionId { get; set; }
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,8 @@
namespace qtc_api.Dtos.User
{
public class UserStockActionResultDto
{
public int StockAmount { get; set; }
public int CurrencyAmount { get; set; }
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,13 @@
namespace qtc_api.Enums
{
public enum GameStatus
{
WaitingForPlayer,
SelectingSymbol,
Ongoing,
P1Win,
P2Win,
NoWin,
PlayerDisconnected
}
}

View File

@ -0,0 +1,8 @@
namespace qtc_api.Enums
{
public enum StoreItemType
{
ProfileCosmetic = 1,
ClientCosmetic = 2
}
}

View File

@ -0,0 +1,9 @@
namespace qtc_api.Enums
{
public enum TicTacToeSymbol
{
X,
O,
Blank
}
}

View File

@ -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<T>(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<T?> GetRecordAsync<T>(this IDistributedCache cache, string recordId)
{
var jsonData = await cache.GetStringAsync(recordId);
if (jsonData == null)
{
return default;
}
return JsonSerializer.Deserialize<T>(jsonData);
}
public static async Task<byte[]?> GetImageAsync(this IDistributedCache cache, string recordId)
{
var bytes = await cache.GetAsync(recordId);
if (bytes == null)
{
return default;
}
return bytes;
}
}
}

View File

@ -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<ChatHub> _logger;
private DataContext _dataContext;
private ServerConfig _serverConfig;
public static readonly Dictionary<string, List<User>> GroupUsers = [];
public static readonly Dictionary<string, List<string>> ConnectedUsers = [];
public static readonly List<User> OnlineUsers = [];
private static User ServerUser = new()
{
Id = "0",
Username = "Server",
};
public ChatHub(IUserService userService, IRoomService roomService, ILogger<ChatHub> 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<string>? 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<string>? 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<User>()); }
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<Room> 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<Room> 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<string>? 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);
}
}

View File

@ -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<TicTacToeHub> _logger;
private GameRoomService _gameRoomService;
public TicTacToeHub(ILogger<TicTacToeHub> 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);
}
}
}

View File

@ -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 }
}
}

View File

@ -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();
}
}

View File

@ -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; }
}
}

View File

@ -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!;
}
}

View File

@ -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;
}
}

View File

@ -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; }
}
}

View File

@ -0,0 +1,9 @@
namespace qtc_api.Models
{
public class ServiceResponse<T>
{
public T? Data { get; set; }
public bool Success { get; set; } = false;
public string Message { get; set; } = string.Empty;
}
}

View File

@ -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<RefreshToken>? RefreshTokens { get; }
public virtual IEnumerable<Contact>? ContactsMade { get; }
public virtual IEnumerable<Contact>? ContactsList { get; }
public virtual IEnumerable<OwnedStoreItem>? OwnedStoreItems { get; }
}
}

98
qtc-net-server/Program.cs Normal file
View File

@ -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<DataContext>();
builder.Services.AddSignalR().AddHubOptions<ChatHub>(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<IEmailService, EmailService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IRoomService, RoomService>();
builder.Services.AddScoped<IContactService, ContactService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<IBucketService, BucketService>();
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<ServerConfig>(_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<CurrencyGamesService>();
builder.Services.AddSingleton<GameRoomService>();
builder.Services.AddHostedService(provider => provider.GetService<CurrencyGamesService>()!);
var app = builder.Build();
using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<DataContext>().Database.EnsureCreatedAsync();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<ChatHub>("/chat");
app.MapHub<TicTacToeHub>("/tttgame");
app.Run();

View File

@ -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."
}

View File

@ -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"
}
]

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,8 @@
namespace qtc_api.Schema
{
public class TicTacToeMove
{
public User User { get; set; } = new();
public int Point { get; set; }
}
}

View File

@ -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<BucketService> _logger;
private bool Enabled { get; set; }
public BucketService(IConfiguration config, ILogger<BucketService> logger)
{
_config = config;
_logger = logger;
var serviceUrl = _config["S3Config:S3ServiceUrl"];
var accessKey = _config["S3Config:S3AccessKey"];
var secretKey = _config["S3Config:S3SecretKey"];
var profileImagesBucket = _config["S3Config:S3ProfileImagesBucket"];
var cdnBucket = _config["S3Config:S3CDNBucket"];
var imagesBucket = _config["S3Config:S3ImagesBucket"];
var enabled = _config.GetValue<bool>("S3Config:S3Enabled");
if (serviceUrl != null) ServiceUrl = serviceUrl;
if (accessKey != null) AccessKey = accessKey;
if (secretKey != null) SecretKey = secretKey;
if (profileImagesBucket != null) ProfileImagesBucketName = profileImagesBucket;
if (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<bool> PutProfileImage(string userId, string imageName, byte[] imageBytes)
{
if (!Enabled)
{
_logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded.");
return false;
}
try
{
using var stream = new MemoryStream(imageBytes);
var request = new PutObjectRequest()
{
Key = $@"{userId}/{imageName}",
BucketName = ProfileImagesBucketName,
InputStream = stream,
DisablePayloadSigning = true,
DisableDefaultChecksumValidation = true
};
var response = await S3Client.PutObjectAsync(request);
return response.HttpStatusCode == System.Net.HttpStatusCode.OK || response.HttpStatusCode == System.Net.HttpStatusCode.Accepted;
}
catch (AmazonS3Exception ex)
{
_logger.LogError($"S3 Bucket Threw An Exception\n{ex.Message}\n{ex.StackTrace}");
return false;
}
}
public async Task<byte[]?> GetProfileImageBytes(string userId, string imageName)
{
if (!Enabled)
{
_logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded.");
return default;
}
try
{
var response = await S3Client.GetObjectAsync(ProfileImagesBucketName, $@"{userId}/{imageName}");
if (response != null)
{
using var stream = response.ResponseStream;
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
else return default;
}
catch (AmazonS3Exception ex)
{
_logger.LogError($"S3 Bucket Threw An Exception\n{ex.Message}\n{ex.StackTrace}");
return default;
}
}
public async Task<byte[]?> 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<bool> DeleteProfileImage(string userId, string imageName)
{
if (!Enabled)
{
_logger.LogWarning("Not Using S3 Bucket. Performance May Be Degraded.");
return false;
}
try
{
var response = await S3Client.DeleteObjectAsync(ProfileImagesBucketName, $@"{userId}/{imageName}");
return response.HttpStatusCode == System.Net.HttpStatusCode.OK || response.HttpStatusCode == System.Net.HttpStatusCode.Accepted;
}
catch (AmazonS3Exception ex)
{
_logger.LogError($"S3 Bucket Threw An Exception\n{ex.Message}\n{ex.StackTrace}");
return false;
}
}
}
}

View File

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

View File

@ -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<ServiceResponse<Contact>> CreateContact(string ownerId, string userId)
{
var serviceResponse = new ServiceResponse<Contact>();
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<ServiceResponse<bool>> UpdateContactStatus(string ownerId, string userId, Contact.ContactStatus ownerStatus, Contact.ContactStatus userStatus)
{
var serviceResponse = new ServiceResponse<bool>();
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<ServiceResponse<Contact>> DeleteContact(string ownerId, string userId)
{
var serviceResponse = new ServiceResponse<Contact>();
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<ServiceResponse<Contact>> GetContactById(string id)
{
var serviceResponse = new ServiceResponse<Contact>();
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<ServiceResponse<List<Contact>>> GetUserContacts(User user)
{
var serviceResponse = new ServiceResponse<List<Contact>>();
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<Contact>();
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;
}
}
}

View File

@ -0,0 +1,11 @@
namespace qtc_api.Services.ContactService
{
public interface IContactService
{
public Task<ServiceResponse<Contact>> CreateContact(string ownerId, string userId);
public Task<ServiceResponse<bool>> UpdateContactStatus(string ownerId, string userId, Contact.ContactStatus ownerStatus, Contact.ContactStatus userStatus);
public Task<ServiceResponse<List<Contact>>> GetUserContacts(User user);
public Task<ServiceResponse<Contact>> GetContactById(string id);
public Task<ServiceResponse<Contact>> DeleteContact(string ownerId, string userId);
}
}

View File

@ -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<CurrencyGamesService> logger;
public CurrencyGamesService(IServiceScopeFactory scopeFactory, ILogger<CurrencyGamesService> 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<int> GetCurrentPricePerStock() { return new ServiceResponse<int> { Success = true, Data = CurrentPricePerStock }; }
public async Task<ServiceResponse<UserStockActionResultDto>> BuyStock(User user, int amount)
{
using (var scope = _scopeFactory.CreateScope())
{
var userServiceScoped = scope.ServiceProvider.GetRequiredService<IUserService>();
int total = 0;
for (int i = 0; i < amount; i++)
{
total += CurrentPricePerStock;
}
if (total > user.CurrencyAmount) return new ServiceResponse<UserStockActionResultDto> { 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<UserStockActionResultDto> { Success = true, Data = new UserStockActionResultDto { StockAmount = stockResult.Data, CurrencyAmount = currencyResult.Data } };
}
}
public async Task<ServiceResponse<UserStockActionResultDto>> SellStock(User user, int amount)
{
using (var scope = _scopeFactory.CreateScope())
{
var userServiceScoped = scope.ServiceProvider.GetRequiredService<IUserService>();
if (amount > user.StockAmount) return new ServiceResponse<UserStockActionResultDto> { 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<UserStockActionResultDto> { Success = true, Data = new UserStockActionResultDto { StockAmount = stockResult.Data, CurrencyAmount = currencyResult.Data } };
}
}
// Number Guess
public ServiceResponse<int> GetRandomNumber()
{
Random rnd = new Random();
return new ServiceResponse<int> { Success = true, Data = rnd.Next(1, 100) };
}
public ServiceResponse<NumberGuessResult> GuessRandomNumber(int original, int guess)
{
var response = new ServiceResponse<NumberGuessResult> { 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
}
}
}

View File

@ -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<EmailService> _logger;
public EmailService(IConfiguration configuration, ILogger<EmailService> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task SendConfirmationEmail(string email, string name, string confirmUrl)
{
// get config entries
bool confirmationRequired = _configuration.GetValue<bool>("EmailConfig:EmailConfirmationRequired");
string? host = _configuration.GetValue<string>("EmailConfig:SMTPServer");
int? port = _configuration.GetValue<int?>("EmailConfig:SMTPPort");
string? username = _configuration.GetValue<string>("EmailConfig:SMTPUsername");
string? password = _configuration.GetValue<string>("EmailConfig:SMTPPassword");
string? senderAddress = _configuration.GetValue<string>("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($"<h2>Hello {name},</h2>");
emailBody.AppendLine("<p>Your receiving this message because you made a QtC.NET Account on a server that requires email confirmation.<br>");
emailBody.AppendLine(@$"You can confirm your account by clicking <a href=""{confirmUrl}"">here.</a><br>");
emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.<br><br>");
emailBody.AppendLine("If you did not create a QtC.NET account on any server, you may simply ignore this email.</p>");
// 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<bool>("EmailConfig:EmailConfirmationRequired");
string? host = _configuration.GetValue<string>("EmailConfig:SMTPServer");
int? port = _configuration.GetValue<int?>("EmailConfig:SMTPPort");
string? username = _configuration.GetValue<string>("EmailConfig:SMTPUsername");
string? password = _configuration.GetValue<string>("EmailConfig:SMTPPassword");
string? senderAddress = _configuration.GetValue<string>("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($"<h2>Hello {name},</h2>");
emailBody.AppendLine("<p>Your receiving this message because you requested a password reset on a QtC.NET Account.<br>");
emailBody.AppendLine(@$"You can reset your password by clicking <a href=""{passwordResetUrl}"">here.</a><br>");
emailBody.AppendLine("NOTE: This Link Is Only Valid For 24 Hours.<br><br>");
emailBody.AppendLine("If you did not request a password reset on any QtC.NET account on any server, you may simply ignore this email.</p>");
// 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);
}
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,10 @@
namespace qtc_api.Services.GameRoomService
{
public class GameRoomService
{
public List<GameRoom> GameRooms { get; set; } = [];
public void AddRoom(GameRoom room) => GameRooms.Add(room);
public void RemoveRoom(GameRoom room) => GameRooms.Remove(room);
}
}

View File

@ -0,0 +1,11 @@
namespace qtc_api.Services.RoomService
{
public interface IRoomService
{
public Task<ServiceResponse<Room>> GetRoom(string id);
public Task<ServiceResponse<List<Room>>> GetAllRooms();
public Task<ServiceResponse<Room>> AddRoom(string userId, RoomDto room);
public Task<ServiceResponse<Room>> UpdateRoom(Room room);
public Task<ServiceResponse<Room>> DeleteRoom(string id);
}
}

View File

@ -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<ServiceResponse<Room>> AddRoom(string userId, RoomDto room)
{
var serviceResponse = new ServiceResponse<Room>();
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<ServiceResponse<Room>> DeleteRoom(string id)
{
var serviceResponse = new ServiceResponse<Room>();
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<ServiceResponse<List<Room>>> GetAllRooms()
{
var serviceResponse = new ServiceResponse<List<Room>>();
var rooms = await _dataContext.Rooms.ToListAsync();
serviceResponse.Success = true;
serviceResponse.Data = rooms;
return serviceResponse;
}
public async Task<ServiceResponse<Room>> GetRoom(string id)
{
var serviceResponse = new ServiceResponse<Room>();
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<ServiceResponse<Room>> 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;
}
}
}

View File

@ -0,0 +1,95 @@
using System.Text.Json;
namespace qtc_api.Services.StoreService
{
public class StoreService
{
public static List<StoreItem> 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<StoreItem[]>(File.ReadAllText(storeJsonLocation));
if (storeItems != null && storeItems.Length > 0)
{
StoreItems = storeItems.ToList();
}
}
public ServiceResponse<List<StoreItem>> GetStoreItems()
{
return new ServiceResponse<List<StoreItem>> { Success = true, Data = StoreItems };
}
public ServiceResponse<StoreItem> GetStoreItem(int id)
{
var item = StoreItems.FirstOrDefault(e => e.Id == id);
if (item != null)
return new ServiceResponse<StoreItem> { Success = true, Data = item };
else return new ServiceResponse<StoreItem> { Success = false, Message = "Item Not Found" };
}
public ServiceResponse<OwnedStoreItem> 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<OwnedStoreItem> { Success = true, Data = item };
else return new ServiceResponse<OwnedStoreItem> { Success = false, Message = "Item Not Yet Purchased" };
}
public ServiceResponse<List<OwnedStoreItem>> 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<List<OwnedStoreItem>> { Success = true, Data = items };
else return new ServiceResponse<List<OwnedStoreItem>> { Success = false, Message = "User Owns No Items" };
}
public async Task<ServiceResponse<bool>> 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<bool> { Success = true, Data = true };
}
else return new ServiceResponse<bool> { Success = false, Message = "Insufficient Currency" };
}
else return new ServiceResponse<bool> { Success = false, Message = "User Not Found" };
}
else return new ServiceResponse<bool> { Success = false, Message = "Item Not Found" };
}
}
}

View File

@ -0,0 +1,12 @@
namespace qtc_api.Services.TokenService
{
public interface ITokenService
{
public Task<ServiceResponse<string>> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken = true, bool remember = false);
public ServiceResponse<string> GenerateEmailConfirmationToken(User user);
public ServiceResponse<string> GeneratePasswordResetConfirmationToken(User user);
public Task<ServiceResponse<bool>> ValidateAccessToken(string accessToken);
public Task<ServiceResponse<string>> ValidateRefreshToken(string refreshToken);
public ServiceResponse<TokenValidationParameters> GetValidationParams();
}
}

View File

@ -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<ServiceResponse<string>> GenerateAccessTokenAndRefreshToken(User user, bool generateRefToken, bool remember)
{
var serviceResponse = new ServiceResponse<string>();
// Generate JWT Access Token
List<Claim> claims = new List<Claim>()
{
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<string> GenerateEmailConfirmationToken(User user)
{
var serviceResponse = new ServiceResponse<string>();
// Generate JWT Access Token
List<Claim> claims = new List<Claim>()
{
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<string> GeneratePasswordResetConfirmationToken(User user)
{
var serviceResponse = new ServiceResponse<string>();
// Generate JWT Access Token
List<Claim> claims = new List<Claim>()
{
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<string> GenerateClientPasswordResetConfirmationToken(User user)
{
var serviceResponse = new ServiceResponse<string>();
// Generate JWT Access Token
List<Claim> claims = new List<Claim>()
{
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<ServiceResponse<string>> ValidateRefreshToken(string refreshToken)
{
var serviceResponse = new ServiceResponse<string>();
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<ServiceResponse<bool>> ValidateAccessToken(string accessToken)
{
var serviceResponse = new ServiceResponse<bool>();
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<TokenValidationParameters> GetValidationParams()
{
var serviceResponse = new ServiceResponse<TokenValidationParameters>();
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;
}
}
}

View File

@ -0,0 +1,21 @@
namespace qtc_api.Services.UserService
{
public interface IUserService
{
public Task<ServiceResponse<User>> GetUserById(string id);
public Task<ServiceResponse<UserInformationDto>> GetUserInformationById(string id);
public Task<ServiceResponse<User>> GetUserByEmail(string email);
public Task<ServiceResponse<List<UserInformationDto>>> GetAllUsers();
public Task<ServiceResponse<List<UserInformationDto>>> GetAllOnlineUsers();
public Task<ServiceResponse<User>> AddUser(UserDto userReq);
public Task<ServiceResponse<UserInformationDto>> UpdateUserInfo(UserUpdateInformationDto request);
public Task<ServiceResponse<string>> UpdateUserPic(string userId, IFormFile file);
public Task<ServiceResponse<FileContentResult>> GetUserPic(string userId);
public Task<ServiceResponse<UserStatusDto>> UpdateStatus(UserStatusDto request);
public Task<ServiceResponse<User>> DeleteUser(string id);
public Task<ServiceResponse<int>> AddCurrencyToUser(string id, int amount, bool isSpinClaim);
public Task<ServiceResponse<int>> RemoveCurrencyFromUser(string id, int amount);
public Task<ServiceResponse<int>> AddStockToUser(string id, int amount);
public Task<ServiceResponse<int>> RemoveStockFromUser(string id, int amount);
}
}

View File

@ -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<ServiceResponse<User>> AddUser(UserDto userReq)
{
var serviceResponse = new ServiceResponse<User>();
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<ServiceResponse<User>> DeleteUser(string id)
{
var serviceResponse = new ServiceResponse<User>();
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<ServiceResponse<List<UserInformationDto>>> GetAllUsers()
{
var serviceResponse = new ServiceResponse<List<UserInformationDto>>();
var userList = await _dataContext.Users.ToListAsync();
var userInfoList = new List<UserInformationDto>();
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<ServiceResponse<List<UserInformationDto>>> GetAllOnlineUsers()
{
var serviceResponse = new ServiceResponse<List<UserInformationDto>>();
var onlineUsers = new List<UserInformationDto>();
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<ServiceResponse<User>> GetUserById(string id)
{
var serviceResponse = new ServiceResponse<User>();
var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Id == id);
serviceResponse.Success = true;
serviceResponse.Data = user;
return serviceResponse;
}
public async Task<ServiceResponse<UserInformationDto>> GetUserInformationById(string id)
{
var serviceResponse = new ServiceResponse<UserInformationDto>();
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<string> _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<ServiceResponse<User>> GetUserByEmail(string email)
{
var serviceResponse = new ServiceResponse<User>();
var user = await _dataContext.Users.FirstOrDefaultAsync(x => x.Email == email);
serviceResponse.Success = true;
serviceResponse.Data = user;
return serviceResponse;
}
public async Task<ServiceResponse<UserInformationDto>> UpdateUserInfo(UserUpdateInformationDto request)
{
var serviceResponse = new ServiceResponse<UserInformationDto>();
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<ServiceResponse<string>> UpdateUserPic(string userId, IFormFile file)
{
var serviceResponse = new ServiceResponse<string>();
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<ServiceResponse<FileContentResult>> GetUserPic(string userId)
{
var serviceResponse = new ServiceResponse<FileContentResult>();
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<ServiceResponse<UserStatusDto>> UpdateStatus(UserStatusDto request)
{
var serviceResponse = new ServiceResponse<UserStatusDto>();
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<ServiceResponse<int>> AddCurrencyToUser(string id, int amount, bool isSpinClaim)
{
var response = new ServiceResponse<int>();
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<ServiceResponse<int>> RemoveCurrencyFromUser(string id, int amount)
{
var response = new ServiceResponse<int>();
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<ServiceResponse<int>> AddStockToUser(string id, int amount)
{
var response = new ServiceResponse<int>();
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<ServiceResponse<int>> RemoveStockFromUser(string id, int amount)
{
var response = new ServiceResponse<int>();
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;
}
}
}

View File

@ -0,0 +1,5 @@
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": []
}

View File

@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>qtc_api</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.11.2" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.7" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.7" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.13.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="SIPSorcery" Version="10.0.11" />
<PackageReference Include="StackExchange.Redis" Version="2.8.58" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.13.0" />
</ItemGroup>
<ItemGroup>
<Content Update="Resources\store.Development.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Update="Resources\store.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Update="Resources\ServerConfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>