diff --git a/Shogi.Api/ApiKeys.cs b/Shogi.Api/ApiKeys.cs
new file mode 100644
index 0000000..f19377d
--- /dev/null
+++ b/Shogi.Api/ApiKeys.cs
@@ -0,0 +1,6 @@
+namespace Shogi.Api;
+
+public class ApiKeys
+{
+ public string BrevoEmailService { get; set; } = string.Empty;
+}
diff --git a/Shogi.Api/Application/GameHub.cs b/Shogi.Api/Application/GameHub.cs
new file mode 100644
index 0000000..4eb4b2d
--- /dev/null
+++ b/Shogi.Api/Application/GameHub.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace Shogi.Api.Application;
+
+///
+/// Used to receive signals from connected clients.
+///
+public class GameHub : Hub
+{
+ public Task Subscribe(string sessionId)
+ {
+ return this.Groups.AddToGroupAsync(this.Context.ConnectionId, sessionId);
+ }
+
+ public Task Unsubscribe(string sessionId)
+ {
+ return this.Groups.RemoveFromGroupAsync(this.Context.ConnectionId, sessionId);
+ }
+}
diff --git a/Shogi.Api/Application/GameHubContext.cs b/Shogi.Api/Application/GameHubContext.cs
new file mode 100644
index 0000000..cbcead0
--- /dev/null
+++ b/Shogi.Api/Application/GameHubContext.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace Shogi.Api.Application;
+
+///
+/// Used to send signals to connected clients.
+///
+public class GameHubContext(IHubContext context)
+{
+ public async Task Emit_SessionJoined(string sessionId)
+ {
+ var clients = context.Clients.Group(sessionId);
+ await clients.SendAsync("SessionJoined");
+ }
+
+ public async Task Emit_PieceMoved(string sessionId)
+ {
+ var clients = context.Clients.Group(sessionId);
+ await clients.SendAsync("PieceMoved");
+ }
+}
diff --git a/Shogi.Api/Application/ShogiApplication.cs b/Shogi.Api/Application/ShogiApplication.cs
new file mode 100644
index 0000000..7e6096e
--- /dev/null
+++ b/Shogi.Api/Application/ShogiApplication.cs
@@ -0,0 +1,143 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Shogi.Api.Controllers;
+using Shogi.Api.Extensions;
+using Shogi.Api.Identity;
+using Shogi.Api.Repositories;
+using Shogi.Api.Repositories.Dto;
+using Shogi.Contracts.Api;
+using Shogi.Domain.Aggregates;
+using System.Data.SqlClient;
+
+namespace Shogi.Api.Application;
+
+public class ShogiApplication(
+ QueryRepository queryRepository,
+ SessionRepository sessionRepository,
+ UserManager userManager,
+ GameHubContext gameHubContext)
+{
+
+ public async Task CreateSession(string playerId)
+ {
+ var session = new Session(Guid.NewGuid(), playerId);
+
+ try
+ {
+ await sessionRepository.CreateSession(session);
+ return new CreatedAtActionResult(
+ nameof(SessionsController.GetSession),
+ null,
+ new { sessionId = session.Id.ToString() },
+ session.Id.ToString());
+ }
+ catch (SqlException)
+ {
+ return new ConflictResult();
+ }
+ }
+
+ public async Task> ReadAllSessionMetadatas(string playerId)
+ {
+ return await queryRepository.ReadSessionsMetadata(playerId);
+ }
+
+ public async Task ReadSession(string id)
+ {
+ var (sessionDto, moveDtos) = await sessionRepository.ReadSessionAndMoves(id);
+ if (!sessionDto.HasValue)
+ {
+ return null;
+ }
+
+ var session = new Session(Guid.Parse(sessionDto.Value.Id), sessionDto.Value.Player1Id);
+ if (!string.IsNullOrWhiteSpace(sessionDto.Value.Player2Id)) session.AddPlayer2(sessionDto.Value.Player2Id);
+
+ foreach (var move in moveDtos)
+ {
+ if (move.PieceFromHand.HasValue)
+ {
+ session.Board.Move(move.PieceFromHand.Value, move.To);
+ }
+ else if (move.From != null)
+ {
+ session.Board.Move(move.From, move.To, false);
+ }
+ else
+ {
+ throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}");
+ }
+ }
+
+
+
+ return session;
+ }
+
+ public async Task MovePiece(string playerId, string sessionId, MovePieceCommand command)
+ {
+ var session = await this.ReadSession(sessionId);
+ if (session == null)
+ {
+ return new NotFoundResult();
+ }
+
+ if (!session.IsSeated(playerId))
+ {
+ return new ForbidResult();
+ }
+
+ try
+ {
+ if (command.PieceFromHand.HasValue)
+ {
+ session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To);
+ }
+ else
+ {
+ session.Board.Move(command.From!, command.To, command.IsPromotion ?? false);
+ }
+ }
+ catch (InvalidOperationException e)
+ {
+ return new ConflictObjectResult(e.Message);
+ }
+
+ await sessionRepository.CreateMove(sessionId, command);
+
+ await gameHubContext.Emit_PieceMoved(sessionId);
+
+ return new NoContentResult();
+
+ }
+
+ public async Task JoinSession(string sessionId, string player2Id)
+ {
+ var session = await this.ReadSession(sessionId);
+ if (session == null) return new NotFoundResult();
+
+ if (string.IsNullOrEmpty(session.Player2))
+ {
+ session.AddPlayer2(player2Id);
+
+ await sessionRepository.SetPlayer2(sessionId, player2Id);
+
+ var player2Email = this.GetUsername(player2Id);
+ await gameHubContext.Emit_SessionJoined(sessionId);
+
+ return new OkResult();
+ }
+
+ return new ConflictObjectResult("This game already has two players.");
+ }
+
+ public string GetUsername(string? userId)
+ {
+ if (string.IsNullOrEmpty(userId))
+ {
+ return string.Empty;
+ }
+
+ return userManager.Users.FirstOrDefault(u => u.Id == userId)?.UserName!;
+ }
+}
diff --git a/Shogi.Api/Controllers/AccountController.cs b/Shogi.Api/Controllers/AccountController.cs
new file mode 100644
index 0000000..4f89005
--- /dev/null
+++ b/Shogi.Api/Controllers/AccountController.cs
@@ -0,0 +1,74 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Shogi.Api.Identity;
+using System.Security.Claims;
+
+namespace Shogi.Api.Controllers;
+
+[Authorize]
+[Route("[controller]")]
+[ApiController]
+public class AccountController(
+ SignInManager signInManager,
+ UserManager UserManager,
+ IConfiguration configuration) : ControllerBase
+{
+ [Authorize("Admin")]
+ [HttpPost("TestAccount")]
+ public async Task CreateTestAccounts()
+ {
+ var newUser = new ShogiUser { UserName = "aat-account", Email = "test-account@lucaserver.space", EmailConfirmed = true };
+ var newUser2 = new ShogiUser { UserName = "aat-account-2", Email = "test-account2@lucaserver.space", EmailConfirmed = true };
+ var pass = configuration["TestUserPassword"] ?? throw new InvalidOperationException("TestUserPassword not configured.");
+ var result = await UserManager.CreateAsync(newUser, pass);
+ if (result != null && !result.Succeeded)
+ {
+ return this.Problem(string.Join(",", result.Errors.Select(e => e.Description)));
+ }
+
+ result = await UserManager.CreateAsync(newUser2, pass);
+ if(result != null && !result.Succeeded)
+ {
+ return this.Problem(string.Join(",", result.Errors.Select(e => e.Description)));
+ }
+
+ return this.Created();
+ }
+
+ [HttpPost("/logout")]
+ public async Task Logout([FromBody] object empty)
+ {
+ // https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support
+ if (empty is not null)
+ {
+ await signInManager.SignOutAsync();
+
+ return this.Ok();
+ }
+
+ return this.Unauthorized();
+ }
+
+ [HttpGet("/roles")]
+ public IActionResult GetRoles()
+ {
+ if (this.User.Identity is not null && this.User.Identity.IsAuthenticated)
+ {
+ var identity = (ClaimsIdentity)this.User.Identity;
+ var roles = identity.FindAll(identity.RoleClaimType)
+ .Select(c => new
+ {
+ c.Issuer,
+ c.OriginalIssuer,
+ c.Type,
+ c.Value,
+ c.ValueType
+ });
+
+ return this.Ok(roles);
+ }
+
+ return this.Unauthorized();
+ }
+}
diff --git a/Shogi.Api/Controllers/Extentions.cs b/Shogi.Api/Controllers/Extentions.cs
new file mode 100644
index 0000000..eb828cb
--- /dev/null
+++ b/Shogi.Api/Controllers/Extentions.cs
@@ -0,0 +1,11 @@
+using System.Security.Claims;
+
+namespace Shogi.Api.Controllers;
+
+public static class Extentions
+{
+ public static string? GetId(this ClaimsPrincipal self)
+ {
+ return self.Claims.FirstOrDefault(claim => claim.Type == ClaimTypes.NameIdentifier)?.Value;
+ }
+}
diff --git a/Shogi.Api/Controllers/SessionsController.cs b/Shogi.Api/Controllers/SessionsController.cs
index 38842a7..0f929f5 100644
--- a/Shogi.Api/Controllers/SessionsController.cs
+++ b/Shogi.Api/Controllers/SessionsController.cs
@@ -1,169 +1,123 @@
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
+using Shogi.Api.Application;
using Shogi.Api.Extensions;
-using Shogi.Api.Managers;
+using Shogi.Api.Identity;
using Shogi.Api.Repositories;
using Shogi.Contracts.Api;
-using Shogi.Contracts.Socket;
using Shogi.Contracts.Types;
-using System.Data.SqlClient;
+using System.Security.Claims;
namespace Shogi.Api.Controllers;
+[Authorize]
[ApiController]
[Route("[controller]")]
-[Authorize]
-public class SessionsController : ControllerBase
+public class SessionsController(
+ SessionRepository sessionRepository,
+ ShogiApplication application,
+ SignInManager signInManager,
+ UserManager userManager) : ControllerBase
{
- private readonly ISocketConnectionManager communicationManager;
- private readonly IModelMapper mapper;
- private readonly ISessionRepository sessionRepository;
- private readonly IQueryRespository queryRespository;
- private readonly ILogger logger;
- public SessionsController(
- ISocketConnectionManager communicationManager,
- IModelMapper mapper,
- ISessionRepository sessionRepository,
- IQueryRespository queryRespository,
- ILogger logger)
- {
- this.communicationManager = communicationManager;
- this.mapper = mapper;
- this.sessionRepository = sessionRepository;
- this.queryRespository = queryRespository;
- this.logger = logger;
- }
+ [HttpPost]
+ public async Task CreateSession()
+ {
+ var id = this.User.GetId();
+ if (string.IsNullOrEmpty(id))
+ {
+ return this.Unauthorized();
+ }
+ return await application.CreateSession(id);
+ }
- [HttpPost]
- public async Task CreateSession([FromBody] CreateSessionCommand request)
- {
- var userId = User.GetShogiUserId();
- var session = new Domain.Session(request.Name, userId);
- try
- {
- await sessionRepository.CreateSession(session);
- }
- catch (SqlException e)
- {
- logger.LogError(exception: e, message: "Uh oh");
- return this.Conflict();
- }
+ [HttpDelete("{sessionId}")]
+ public async Task DeleteSession(string sessionId)
+ {
+ var id = this.User.GetId();
+ if (id == null)
+ {
+ return this.Unauthorized();
+ }
- await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage());
- return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null);
- }
+ var (session, _) = await sessionRepository.ReadSessionAndMoves(sessionId);
+ if (!session.HasValue) return this.NoContent();
- [HttpDelete("{name}")]
- public async Task DeleteSession(string name)
- {
- var userId = User.GetShogiUserId();
- var session = await sessionRepository.ReadSession(name);
+ if (session.Value.Player1Id == id)
+ {
+ await sessionRepository.DeleteSession(sessionId);
+ return this.NoContent();
+ }
- if (session == null) return this.NoContent();
+ return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others.");
+ }
- if (session.Player1 == userId)
- {
- await sessionRepository.DeleteSession(name);
- return this.NoContent();
- }
+ ///
+ /// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
+ ///
+ ///
+ ///
+ [HttpGet("{sessionId}")]
+ public async Task> GetSession(Guid sessionId)
+ {
+ var session = await application.ReadSession(sessionId.ToString());
+ if (session == null) return this.NotFound();
- return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others.");
- }
+ return new Session
+ {
+ BoardState = new BoardState
+ {
+ Board = session.Board.BoardState.State.ToContract(),
+ Player1Hand = session.Board.BoardState.Player1Hand.ToContract(),
+ Player2Hand = session.Board.BoardState.Player2Hand.ToContract(),
+ PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(),
+ WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract()
+ },
+ Player1 = application.GetUsername(session.Player1),
+ Player2 = application.GetUsername(session.Player2),
+ SessionId = session.Id
+ };
+ }
- [HttpGet("PlayerCount")]
- public async Task> GetSessionsPlayerCount()
- {
- return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId()));
- }
+ [HttpGet()]
+ public async Task> ReadAllSessionsMetadata()
+ {
+ var id = this.User.GetId();
+ if (id == null) return this.Unauthorized();
- ///
- /// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
- ///
- ///
- ///
- [HttpGet("{name}")]
- public async Task> GetSession(string name)
- {
- var session = await sessionRepository.ReadSession(name);
- if (session == null) return this.NotFound();
+ var dtos = await application.ReadAllSessionMetadatas(id);
+ return dtos
+ .Select(dto => new SessionMetadata
+ {
+ Player1 = application.GetUsername(dto.Player1Id),
+ Player2 = application.GetUsername(dto.Player2Id),
+ SessionId = Guid.Parse(dto.Id),
+ })
+ .ToArray();
+ }
- var players = await queryRespository.GetUsersForSession(session.Name);
- if (players == null) return this.NotFound();
+ [HttpPatch("{sessionId}/Join")]
+ public async Task JoinSession(string sessionId)
+ {
+ var id = this.User.GetId();
+ if (id == null)
+ {
+ return this.Unauthorized();
+ }
- return new ReadSessionResponse
- {
- Session = new Session
- {
- BoardState = new BoardState
- {
- Board = session.Board.BoardState.State.ToContract(),
- Player1Hand = session.Board.BoardState.Player1Hand.ToContract(),
- Player2Hand = session.Board.BoardState.Player2Hand.ToContract(),
- PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(),
- WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract()
- },
- Player1 = players.Value.Player1,
- Player2 = players.Value.Player2,
- SessionName = session.Name
- }
- };
- }
+ return await application.JoinSession(sessionId, id);
+ }
- [HttpPatch("{name}/Join")]
- public async Task JoinSession(string name)
- {
- var session = await sessionRepository.ReadSession(name);
- if (session == null) return this.NotFound();
+ [HttpPatch("{sessionId}/Move")]
+ public async Task Move([FromRoute] string sessionId, [FromBody] MovePieceCommand command)
+ {
+ var id = this.User.GetId();
+ if (id == null)
+ {
+ return this.Unauthorized();
+ }
- if (string.IsNullOrEmpty(session.Player2))
- {
- session.AddPlayer2(User.GetShogiUserId());
-
- await sessionRepository.SetPlayer2(name, User.GetShogiUserId());
- await communicationManager.BroadcastToAll(new SessionJoinedByPlayerSocketMessage(session.Name));
- return this.Ok();
- }
- return this.Conflict("This game already has two players.");
- }
-
- [HttpPatch("{sessionName}/Move")]
- public async Task Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command)
- {
- var userId = User.GetShogiUserId();
- var session = await sessionRepository.ReadSession(sessionName);
-
- if (session == null) return this.NotFound("Shogi session does not exist.");
-
- if (!session.IsSeated(userId)) return this.StatusCode(StatusCodes.Status403Forbidden, "Player is not a member of the Shogi session.");
-
- try
- {
- if (command.PieceFromHand.HasValue)
- {
- session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To);
- }
- else
- {
- session.Board.Move(command.From!, command.To, command.IsPromotion ?? false);
- }
- }
- catch (InvalidOperationException e)
- {
- return this.Conflict(e.Message);
- }
- await sessionRepository.CreateMove(sessionName, command);
-
- // Send socket message to both players so their clients know that new board state is available.
- await communicationManager.BroadcastToPlayers(
- new PlayerHasMovedMessage
- {
- PlayerName = userId,
- SessionName = session.Name,
- },
- session.Player1,
- session.Player2);
-
- return this.NoContent();
- }
+ return await application.MovePiece(id, sessionId, command);
+ }
}
diff --git a/Shogi.Api/Controllers/UserController.cs b/Shogi.Api/Controllers/UserController.cs
deleted file mode 100644
index 85973da..0000000
--- a/Shogi.Api/Controllers/UserController.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Shogi.Api.Extensions;
-using Shogi.Api.Managers;
-using Shogi.Api.Repositories;
-using Shogi.Contracts.Api;
-
-namespace Shogi.Api.Controllers;
-
-[ApiController]
-[Route("[controller]")]
-[Authorize]
-public class UserController : ControllerBase
-{
- private readonly ISocketTokenCache tokenCache;
- private readonly ISocketConnectionManager connectionManager;
- private readonly IUserRepository userRepository;
- private readonly IShogiUserClaimsTransformer claimsTransformation;
- private readonly AuthenticationProperties authenticationProps;
-
- public UserController(
- ILogger logger,
- ISocketTokenCache tokenCache,
- ISocketConnectionManager connectionManager,
- IUserRepository userRepository,
- IShogiUserClaimsTransformer claimsTransformation)
- {
- this.tokenCache = tokenCache;
- this.connectionManager = connectionManager;
- this.userRepository = userRepository;
- this.claimsTransformation = claimsTransformation;
- authenticationProps = new AuthenticationProperties
- {
- AllowRefresh = true,
- IsPersistent = true
- };
- }
-
- [HttpGet("Token")]
- public ActionResult GetWebSocketToken()
- {
- var userId = User.GetShogiUserId();
- var displayName = User.GetShogiUserDisplayname();
-
- var token = tokenCache.GenerateToken(userId);
- return new CreateTokenResponse
- {
- DisplayName = displayName,
- OneTimeToken = token,
- UserId = userId
- };
- }
-
- ///
- ///
- /// Used by cookie authentication.
- ///
- [AllowAnonymous]
- [HttpGet("LoginAsGuest")]
- public async Task GuestLogin([FromQuery] string? returnUrl)
- {
- var principal = await this.claimsTransformation.CreateClaimsFromGuestPrincipal(User);
- if (principal != null)
- {
- await HttpContext.SignInAsync(
- CookieAuthenticationDefaults.AuthenticationScheme,
- principal,
- authenticationProps
- );
- }
- if (!string.IsNullOrWhiteSpace(returnUrl))
- {
- return Redirect(returnUrl);
- }
- return Ok();
- }
-
- [HttpPut("GuestLogout")]
- public async Task GuestLogout()
- {
- var signOutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
-
- var userId = User?.GetShogiUserId();
- if (!string.IsNullOrEmpty(userId))
- {
- connectionManager.Unsubscribe(userId);
- }
-
- await signOutTask;
- return Ok();
- }
-}
diff --git a/Shogi.Api/ExampleAnonymousSessionMiddleware.cs b/Shogi.Api/ExampleAnonymousSessionMiddleware.cs
deleted file mode 100644
index 9565c0a..0000000
--- a/Shogi.Api/ExampleAnonymousSessionMiddleware.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-namespace Shogi.Api
-{
- namespace anonymous_session.Middlewares
- {
- using Microsoft.AspNetCore.Http;
- using Microsoft.AspNetCore.Authentication;
- using System.Security.Claims;
-
- ///
- /// TODO: Use this example in the guest session logic instead of custom claims.
- ///
- public class ExampleAnonymousSessionMiddleware
- {
- private readonly RequestDelegate _next;
-
- public ExampleAnonymousSessionMiddleware(RequestDelegate next)
- {
- _next = next;
- }
-
- public async System.Threading.Tasks.Task InvokeAsync(HttpContext context)
- {
- if (!context.User.Identity.IsAuthenticated)
- {
- if (string.IsNullOrEmpty(context.User.FindFirstValue(ClaimTypes.Anonymous)))
- {
- var claim = new Claim(ClaimTypes.Anonymous, System.Guid.NewGuid().ToString());
- context.User.AddIdentity(new ClaimsIdentity(new[] { claim }));
-
- string scheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
- await context.SignInAsync(scheme, context.User, new AuthenticationProperties { IsPersistent = false });
- }
- }
-
- await _next(context);
- }
- }
- }
-}
diff --git a/Shogi.Api/Extensions/ClaimsExtensions.cs b/Shogi.Api/Extensions/ClaimsExtensions.cs
deleted file mode 100644
index 76bb571..0000000
--- a/Shogi.Api/Extensions/ClaimsExtensions.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using Microsoft.Identity.Web;
-using System.Security.Claims;
-
-namespace Shogi.Api.Extensions;
-
-public static class ClaimsExtensions
-{
- // https://learn.microsoft.com/en-us/azure/active-directory/develop/id-tokens#payload-claims
-
- ///
- /// Get Id from claims after applying shogi-specific claims transformations.
- ///
- public static string GetShogiUserId(this ClaimsPrincipal self)
- {
- var id = self.GetNameIdentifierId();
- if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("Shogi UserId not found in claims.");
- return id;
- }
-
- ///
- /// Get display name from claims after applying shogi-specific claims transformations.
- ///
- public static string GetShogiUserDisplayname(this ClaimsPrincipal self)
- {
- var displayName = self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
- if (string.IsNullOrEmpty(displayName)) throw new InvalidOperationException("Shogi Display name not found in claims.");
- return displayName;
- }
-
-}
\ No newline at end of file
diff --git a/Shogi.Api/Extensions/LogMiddleware.cs b/Shogi.Api/Extensions/LogMiddleware.cs
deleted file mode 100644
index a3111ec..0000000
--- a/Shogi.Api/Extensions/LogMiddleware.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Logging;
-using System.IO;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace Shogi.Api.Extensions
-{
- public class LogMiddleware
- {
- private readonly RequestDelegate next;
- private readonly ILogger logger;
-
-
- public LogMiddleware(RequestDelegate next, ILoggerFactory factory)
- {
- this.next = next;
- logger = factory.CreateLogger();
- }
-
- public async Task Invoke(HttpContext context)
- {
- try
- {
- await next(context);
- }
- finally
- {
- using var stream = new MemoryStream();
- context.Request?.Body.CopyToAsync(stream);
-
- logger.LogInformation("Request {method} {url} => {statusCode} \n Body: {body}",
- context.Request?.Method,
- context.Request?.Path.Value,
- context.Response?.StatusCode,
- Encoding.UTF8.GetString(stream.ToArray()));
- }
- }
- }
-
- public static class IApplicationBuilderExtensions
- {
- public static IApplicationBuilder UseRequestResponseLogging(this IApplicationBuilder builder)
- {
- builder.UseMiddleware();
- return builder;
- }
- }
-}
diff --git a/Shogi.Api/Identity/ApplicationDbContext.cs b/Shogi.Api/Identity/ApplicationDbContext.cs
new file mode 100644
index 0000000..13be2bb
--- /dev/null
+++ b/Shogi.Api/Identity/ApplicationDbContext.cs
@@ -0,0 +1,8 @@
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace Shogi.Api.Identity;
+
+public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options)
+{
+}
diff --git a/Shogi.Api/Identity/ShogiUser.cs b/Shogi.Api/Identity/ShogiUser.cs
new file mode 100644
index 0000000..22eb31d
--- /dev/null
+++ b/Shogi.Api/Identity/ShogiUser.cs
@@ -0,0 +1,7 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace Shogi.Api.Identity;
+
+public class ShogiUser : IdentityUser
+{
+}
diff --git a/Shogi.Api/Managers/ModelMapper.cs b/Shogi.Api/Managers/ModelMapper.cs
deleted file mode 100644
index 2a31cca..0000000
--- a/Shogi.Api/Managers/ModelMapper.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-using Shogi.Contracts.Types;
-using DomainWhichPiece = Shogi.Domain.ValueObjects.WhichPiece;
-using DomainWhichPlayer = Shogi.Domain.ValueObjects.WhichPlayer;
-using Piece = Shogi.Contracts.Types.Piece;
-
-namespace Shogi.Api.Managers
-{
- public class ModelMapper : IModelMapper
- {
- public WhichPlayer Map(DomainWhichPlayer whichPlayer)
- {
- return whichPlayer switch
- {
- DomainWhichPlayer.Player1 => WhichPlayer.Player1,
- DomainWhichPlayer.Player2 => WhichPlayer.Player2,
- _ => throw new ArgumentException("Unrecognized value for WhichPlayer", nameof(whichPlayer))
- };
- }
-
- public WhichPlayer? Map(DomainWhichPlayer? whichPlayer)
- {
- return whichPlayer.HasValue
- ? Map(whichPlayer.Value)
- : null;
- }
-
- public WhichPiece Map(DomainWhichPiece whichPiece)
- {
- return whichPiece switch
- {
- DomainWhichPiece.King => WhichPiece.King,
- DomainWhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
- DomainWhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
- DomainWhichPiece.Bishop => WhichPiece.Bishop,
- DomainWhichPiece.Rook => WhichPiece.Rook,
- DomainWhichPiece.Knight => WhichPiece.Knight,
- DomainWhichPiece.Lance => WhichPiece.Lance,
- DomainWhichPiece.Pawn => WhichPiece.Pawn,
- _ => throw new ArgumentException("Unrecognized value", nameof(whichPiece)),
- };
- }
-
- public DomainWhichPiece Map(WhichPiece whichPiece)
- {
- return whichPiece switch
- {
- WhichPiece.King => DomainWhichPiece.King,
- WhichPiece.GoldGeneral => DomainWhichPiece.GoldGeneral,
- WhichPiece.SilverGeneral => DomainWhichPiece.SilverGeneral,
- WhichPiece.Bishop => DomainWhichPiece.Bishop,
- WhichPiece.Rook => DomainWhichPiece.Rook,
- WhichPiece.Knight => DomainWhichPiece.Knight,
- WhichPiece.Lance => DomainWhichPiece.Lance,
- WhichPiece.Pawn => DomainWhichPiece.Pawn,
- _ => throw new ArgumentException("Unrecognized value", nameof(whichPiece)),
- };
- }
-
- public Piece Map(Domain.ValueObjects.Piece piece)
- {
- return new Piece { IsPromoted = piece.IsPromoted, Owner = Map(piece.Owner), WhichPiece = Map(piece.WhichPiece) };
- }
-
- public Dictionary Map(IDictionary boardState)
- {
- return boardState.ToDictionary(kvp => kvp.Key.ToUpper(), kvp => MapNullable(kvp.Value));
- }
-
- public Piece? MapNullable(Domain.ValueObjects.Piece? piece)
- {
- if (piece == null) return null;
- return Map(piece);
- }
- }
-
- public interface IModelMapper
- {
- WhichPlayer Map(DomainWhichPlayer whichPlayer);
- WhichPlayer? Map(DomainWhichPlayer? whichPlayer);
- WhichPiece Map(DomainWhichPiece whichPiece);
- DomainWhichPiece Map(WhichPiece value);
- Piece Map(Domain.ValueObjects.Piece p);
- Piece? MapNullable(Domain.ValueObjects.Piece? p);
- Dictionary Map(IDictionary boardState);
- }
-}
diff --git a/Shogi.Api/Managers/SocketConnectionManager.cs b/Shogi.Api/Managers/SocketConnectionManager.cs
deleted file mode 100644
index 6cde2e2..0000000
--- a/Shogi.Api/Managers/SocketConnectionManager.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using Shogi.Contracts.Socket;
-using Shogi.Api.Extensions;
-using System.Collections.Concurrent;
-using System.Net.WebSockets;
-using System.Text.Json;
-
-namespace Shogi.Api.Managers;
-
-public interface ISocketConnectionManager
-{
- Task BroadcastToAll(ISocketMessage response);
- void Subscribe(WebSocket socket, string playerName);
- void Unsubscribe(string playerName);
- Task BroadcastToPlayers(ISocketMessage response, params string?[] playerNames);
-}
-
-///
-/// Retains all active socket connections and provides convenient methods for sending messages to clients.
-///
-public class SocketConnectionManager : ISocketConnectionManager
-{
- /// Dictionary key is player name.
- private readonly ConcurrentDictionary connections;
- private readonly JsonSerializerOptions serializeOptions;
-
- /// Dictionary key is game name.
- private readonly ILogger logger;
-
- public SocketConnectionManager(ILogger logger)
- {
- this.logger = logger;
- this.connections = new ConcurrentDictionary();
- this.serializeOptions = new JsonSerializerOptions(JsonSerializerDefaults.General);
-
- }
-
- public void Subscribe(WebSocket socket, string playerName)
- {
- connections.TryRemove(playerName, out var _);
- connections.TryAdd(playerName, socket);
- }
-
- public void Unsubscribe(string playerName)
- {
- connections.TryRemove(playerName, out _);
- }
-
- public async Task BroadcastToPlayers(ISocketMessage response, params string?[] playerNames)
- {
- var tasks = new List(playerNames.Length);
- foreach (var name in playerNames)
- {
- if (!string.IsNullOrEmpty(name) && connections.TryGetValue(name, out var socket))
- {
- var serialized = Serialize(response);
- logger.LogInformation("Response to {0} \n{1}\n", name, serialized);
- tasks.Add(socket.SendTextAsync(serialized));
- }
- }
- await Task.WhenAll(tasks);
- }
- public Task BroadcastToAll(ISocketMessage response)
- {
- var message = Serialize(response);
- logger.LogInformation("Broadcasting:\n{0}\nDone Broadcasting.", message);
- var tasks = new List(connections.Count);
- foreach (var kvp in connections)
- {
- var socket = kvp.Value;
- try
- {
- tasks.Add(socket.SendTextAsync(message));
- }
- catch (WebSocketException)
- {
- logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
- Unsubscribe(kvp.Key);
- }
- catch
- {
- logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
- Unsubscribe(kvp.Key);
- }
- }
- return Task.WhenAll(tasks);
- }
-
- private string Serialize(object o) => JsonSerializer.Serialize(o, this.serializeOptions);
-}
diff --git a/Shogi.Api/Managers/SocketTokenCache.cs b/Shogi.Api/Managers/SocketTokenCache.cs
deleted file mode 100644
index 15c9291..0000000
--- a/Shogi.Api/Managers/SocketTokenCache.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-
-namespace Shogi.Api.Managers
-{
- public interface ISocketTokenCache
- {
- Guid GenerateToken(string s);
- string? GetUsername(Guid g);
- }
-
- public class SocketTokenCache : ISocketTokenCache
- {
- ///
- /// Key is userName or webSessionId
- ///
- private readonly ConcurrentDictionary Tokens;
-
- public SocketTokenCache()
- {
- Tokens = new ConcurrentDictionary();
- }
-
- public Guid GenerateToken(string userName)
- {
- Tokens.Remove(userName, out _);
-
- var guid = Guid.NewGuid();
- Tokens.TryAdd(userName, guid);
-
- _ = Task.Run(async () =>
- {
- await Task.Delay(TimeSpan.FromMinutes(1));
- Tokens.Remove(userName, out _);
- }).ConfigureAwait(false);
-
- return guid;
- }
-
- /// User name associated to the guid or null.
- public string? GetUsername(Guid guid)
- {
- var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key;
- if (userName != null)
- {
- Tokens.Remove(userName, out _);
- }
- return userName;
- }
- }
-}
diff --git a/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs b/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs
new file mode 100644
index 0000000..24c2203
--- /dev/null
+++ b/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs
@@ -0,0 +1,279 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Shogi.Api.Identity;
+
+#nullable disable
+
+namespace Shogi.Api.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20240816002834_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("Shogi.Api.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Shogi.Api/Migrations/20240816002834_InitialCreate.cs b/Shogi.Api/Migrations/20240816002834_InitialCreate.cs
new file mode 100644
index 0000000..9ddf303
--- /dev/null
+++ b/Shogi.Api/Migrations/20240816002834_InitialCreate.cs
@@ -0,0 +1,224 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Shogi.Api.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "AspNetRoles",
+ columns: table => new
+ {
+ Id = table.Column(type: "nvarchar(450)", nullable: false),
+ Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetRoles", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetUsers",
+ columns: table => new
+ {
+ Id = table.Column(type: "nvarchar(450)", nullable: false),
+ UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ EmailConfirmed = table.Column(type: "bit", nullable: false),
+ PasswordHash = table.Column(type: "nvarchar(max)", nullable: true),
+ SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true),
+ ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true),
+ PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true),
+ PhoneNumberConfirmed = table.Column(type: "bit", nullable: false),
+ TwoFactorEnabled = table.Column(type: "bit", nullable: false),
+ LockoutEnd = table.Column(type: "datetimeoffset", nullable: true),
+ LockoutEnabled = table.Column(type: "bit", nullable: false),
+ AccessFailedCount = table.Column(type: "int", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetUsers", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetRoleClaims",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ RoleId = table.Column(type: "nvarchar(450)", nullable: false),
+ ClaimType = table.Column(type: "nvarchar(max)", nullable: true),
+ ClaimValue = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
+ table.ForeignKey(
+ name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
+ column: x => x.RoleId,
+ principalTable: "AspNetRoles",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetUserClaims",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ UserId = table.Column(type: "nvarchar(450)", nullable: false),
+ ClaimType = table.Column(type: "nvarchar(max)", nullable: true),
+ ClaimValue = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
+ table.ForeignKey(
+ name: "FK_AspNetUserClaims_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetUserLogins",
+ columns: table => new
+ {
+ LoginProvider = table.Column(type: "nvarchar(450)", nullable: false),
+ ProviderKey = table.Column(type: "nvarchar(450)", nullable: false),
+ ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true),
+ UserId = table.Column(type: "nvarchar(450)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
+ table.ForeignKey(
+ name: "FK_AspNetUserLogins_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetUserRoles",
+ columns: table => new
+ {
+ UserId = table.Column(type: "nvarchar(450)", nullable: false),
+ RoleId = table.Column(type: "nvarchar(450)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
+ table.ForeignKey(
+ name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
+ column: x => x.RoleId,
+ principalTable: "AspNetRoles",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_AspNetUserRoles_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetUserTokens",
+ columns: table => new
+ {
+ UserId = table.Column(type: "nvarchar(450)", nullable: false),
+ LoginProvider = table.Column(type: "nvarchar(450)", nullable: false),
+ Name = table.Column(type: "nvarchar(450)", nullable: false),
+ Value = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
+ table.ForeignKey(
+ name: "FK_AspNetUserTokens_AspNetUsers_UserId",
+ column: x => x.UserId,
+ principalTable: "AspNetUsers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AspNetRoleClaims_RoleId",
+ table: "AspNetRoleClaims",
+ column: "RoleId");
+
+ migrationBuilder.CreateIndex(
+ name: "RoleNameIndex",
+ table: "AspNetRoles",
+ column: "NormalizedName",
+ unique: true,
+ filter: "[NormalizedName] IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AspNetUserClaims_UserId",
+ table: "AspNetUserClaims",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AspNetUserLogins_UserId",
+ table: "AspNetUserLogins",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AspNetUserRoles_RoleId",
+ table: "AspNetUserRoles",
+ column: "RoleId");
+
+ migrationBuilder.CreateIndex(
+ name: "EmailIndex",
+ table: "AspNetUsers",
+ column: "NormalizedEmail");
+
+ migrationBuilder.CreateIndex(
+ name: "UserNameIndex",
+ table: "AspNetUsers",
+ column: "NormalizedUserName",
+ unique: true,
+ filter: "[NormalizedUserName] IS NOT NULL");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "AspNetRoleClaims");
+
+ migrationBuilder.DropTable(
+ name: "AspNetUserClaims");
+
+ migrationBuilder.DropTable(
+ name: "AspNetUserLogins");
+
+ migrationBuilder.DropTable(
+ name: "AspNetUserRoles");
+
+ migrationBuilder.DropTable(
+ name: "AspNetUserTokens");
+
+ migrationBuilder.DropTable(
+ name: "AspNetRoles");
+
+ migrationBuilder.DropTable(
+ name: "AspNetUsers");
+ }
+ }
+}
diff --git a/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs b/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..bc2412f
--- /dev/null
+++ b/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -0,0 +1,276 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Shogi.Api.Identity;
+
+#nullable disable
+
+namespace Shogi.Api.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ partial class ApplicationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("RoleId")
+ .HasColumnType("nvarchar(450)");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("Shogi.Api.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Shogi.Api.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Shogi.Api/Models/User.cs b/Shogi.Api/Models/User.cs
deleted file mode 100644
index a0620b6..0000000
--- a/Shogi.Api/Models/User.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System.Collections.ObjectModel;
-
-namespace Shogi.Api.Models;
-
-public class User
-{
- public static readonly ReadOnlyCollection Adjectives = new(new[] {
- "Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly", "Queenly", "Blushing", "Brave",
- "Brainy", "Eager", "Itchy", "Fierce"
- });
- public static readonly ReadOnlyCollection Subjects = new(new[] {
- "Hippo", "Basil", "Mouse", "Walnut", "Minstrel", "Lima Bean", "Koala", "Potato", "Penguin", "Cola", "Banana", "Egg", "Fish", "Yak"
- });
- public static User CreateMsalUser(string id, string displayName) => new(id, displayName, WhichLoginPlatform.Microsoft);
- public static User CreateGuestUser(string id)
- {
- var random = new Random();
- // Adjective
- var index = (int)Math.Floor(random.NextDouble() * Adjectives.Count);
- var adj = Adjectives[index];
- // Subject
- index = (int)Math.Floor(random.NextDouble() * Subjects.Count);
- var subj = Subjects[index];
-
- return new User(id, $"{adj} {subj}", WhichLoginPlatform.Guest);
- }
-
- public string Id { get; }
- public string DisplayName { get; }
-
- public WhichLoginPlatform LoginPlatform { get; }
-
- public User(string id, string displayName, WhichLoginPlatform platform)
- {
- Id = id;
- DisplayName = displayName;
- LoginPlatform = platform;
- }
-}
diff --git a/Shogi.Api/Models/WhichLoginPlatform.cs b/Shogi.Api/Models/WhichLoginPlatform.cs
deleted file mode 100644
index 5972475..0000000
--- a/Shogi.Api/Models/WhichLoginPlatform.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace Shogi.Api.Models
-{
- public enum WhichLoginPlatform
- {
- Unknown,
- Microsoft,
- Guest
- }
-}
diff --git a/Shogi.Api/Program.cs b/Shogi.Api/Program.cs
index b9debee..5fd1b28 100644
--- a/Shogi.Api/Program.cs
+++ b/Shogi.Api/Program.cs
@@ -1,232 +1,106 @@
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.AspNetCore.Http.Json;
-using Microsoft.AspNetCore.HttpLogging;
-using Microsoft.Identity.Web;
-using Microsoft.OpenApi.Models;
-using Shogi.Api.Managers;
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.AspNetCore.ResponseCompression;
+using Microsoft.EntityFrameworkCore;
+using Shogi.Api;
+using Shogi.Api.Application;
+using Shogi.Api.Identity;
using Shogi.Api.Repositories;
-using Shogi.Api.Services;
-namespace Shogi.Api
+var builder = WebApplication.CreateBuilder(args);
+var allowedOrigins = builder
+ .Configuration
+ .GetSection("Cors:AllowedOrigins")
+ .Get() ?? throw new InvalidOperationException("Configuration for allowed origins is missing.");
+
+builder.Services
+ .AddControllers()
+ .AddJsonOptions(options =>
+ {
+ options.JsonSerializerOptions.WriteIndented = true;
+ });
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+builder.Services.AddTransient();
+builder.Services.AddTransient();
+builder.Services.AddTransient();
+builder.Services.AddTransient();
+builder.Services.AddHttpClient();
+builder.Services.Configure(builder.Configuration.GetSection("ApiKeys"));
+
+AddIdentity(builder, builder.Configuration);
+builder.Services.AddSignalR();
+builder.Services.AddResponseCompression(opts =>
{
- public class Program
- {
- public static void Main(string[] args)
- {
- var builder = WebApplication.CreateBuilder(args);
+ opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
+});
+var app = builder.Build();
- var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? throw new InvalidOperationException("Configuration for allowed origins is missing.");
- builder.Services.AddCors(options =>
- {
- options.AddDefaultPolicy(policy =>
- {
- policy
- .WithOrigins(allowedOrigins)
- .SetIsOriginAllowedToAllowWildcardSubdomains()
- .WithExposedHeaders("Set-Cookie")
- .AllowAnyHeader()
- .AllowAnyMethod()
- .AllowCredentials();
- });
- });
- ConfigureAuthentication(builder);
- ConfigureControllers(builder);
- ConfigureSwagger(builder);
- ConfigureDependencyInjection(builder);
- ConfigureLogging(builder);
+app.MapIdentityApi();
- var app = builder.Build();
-
- app.UseWhen(
- // Log anything that isn't related to swagger.
- context => IsNotSwaggerUI(context),
- appBuilder => appBuilder.UseHttpLogging());
-
- // Configure the HTTP request pipeline.
- if (app.Environment.IsDevelopment())
- {
- app.UseHttpsRedirection(); // Apache handles HTTPS in production.
- }
-
- app.UseSwagger();
- app.UseSwaggerUI(options =>
- {
- options.OAuthScopes("api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope");
- options.OAuthConfigObject.ClientId = builder.Configuration["AzureAd:SwaggerUIClientId"];
- options.OAuthConfigObject.UsePkceWithAuthorizationCodeGrant = true;
- });
-
- UseCorsAndWebSockets(app, allowedOrigins);
-
- app.UseAuthentication();
- app.UseAuthorization();
-
- app.Map("/", () => "OK");
- app.MapControllers();
-
- app.Run();
-
- static bool IsNotSwaggerUI(HttpContext context)
- {
- var path = context.Request.GetEncodedPathAndQuery();
-
- return !path.Contains("swagger")
- && !path.Equals("/", StringComparison.Ordinal);
- }
- }
-
- private static void UseCorsAndWebSockets(WebApplication app, string[] allowedOrigins)
- {
-
- // TODO: Figure out how to make a middleware for sockets?
- var socketService = app.Services.GetRequiredService();
- var socketOptions = new WebSocketOptions();
- foreach (var origin in allowedOrigins)
- socketOptions.AllowedOrigins.Add(origin);
-
- app.UseCors();
- app.UseWebSockets(socketOptions);
- app.Use(async (context, next) =>
- {
- if (context.WebSockets.IsWebSocketRequest)
- {
- await socketService.HandleSocketRequest(context);
- }
- else
- {
- await next();
- }
- });
- }
-
- private static void ConfigureLogging(WebApplicationBuilder builder)
- {
- builder.Services.AddHttpLogging(options =>
- {
- options.LoggingFields = HttpLoggingFields.RequestProperties
- | HttpLoggingFields.RequestBody
- | HttpLoggingFields.ResponseStatusCode
- | HttpLoggingFields.ResponseBody;
- });
- }
-
- private static void ConfigureAuthentication(WebApplicationBuilder builder)
- {
- AddJwtAuth(builder);
- AddCookieAuth(builder);
- SetupAuthSwitch(builder);
-
- static void AddJwtAuth(WebApplicationBuilder builder)
- {
- builder.Services
- .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
- }
-
- static void AddCookieAuth(WebApplicationBuilder builder)
- {
- builder.Services
- .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
- .AddCookie(options =>
- {
- options.Cookie.Name = "session-id";
- options.Cookie.SameSite = SameSiteMode.None;
- options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
- options.SlidingExpiration = true;
- options.LoginPath = new PathString("/User/LoginAsGuest");
- });
- }
-
- static void SetupAuthSwitch(WebApplicationBuilder builder)
- {
- var defaultScheme = "CookieOrJwt";
- builder.Services
- .AddAuthentication(defaultScheme)
- .AddPolicyScheme("CookieOrJwt", "Either cookie or jwt", options =>
- {
- options.ForwardDefaultSelector = context =>
- {
- var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false;
- return bearerAuth
- ? JwtBearerDefaults.AuthenticationScheme
- : CookieAuthenticationDefaults.AuthenticationScheme;
- };
- });
- builder
- .Services
- .AddAuthentication(options =>
- {
- options.DefaultAuthenticateScheme = defaultScheme;
- });
- }
- }
-
- private static void ConfigureControllers(WebApplicationBuilder builder)
- {
- builder.Services.AddControllers();
- builder.Services.Configure(options =>
- {
- options.SerializerOptions.WriteIndented = true;
- });
- }
-
- private static void ConfigureDependencyInjection(WebApplicationBuilder builder)
- {
- var services = builder.Services;
- services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- }
-
- private static void ConfigureSwagger(WebApplicationBuilder builder)
- {
- // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
- builder.Services.AddEndpointsApiExplorer();
- builder.Services.AddSwaggerGen(options =>
- {
- var bearerKey = "Bearer";
- options.AddSecurityDefinition(bearerKey, new OpenApiSecurityScheme
- {
- Type = SecuritySchemeType.OAuth2,
- Flows = new OpenApiOAuthFlows
- {
- Implicit = new OpenApiOAuthFlow
- {
- // These urls might be why only my email can login.
- // TODO: Try testing with tenantId in the url instead of "common".
- AuthorizationUrl = new Uri("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
- TokenUrl = new Uri("https://login.microsoftonline.com/common/oauth2/v2.0/token"),
- Scopes = new Dictionary
- {
- { "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope", "Default Scope" },
- { "profile", "profile" },
- { "openid", "openid" }
- }
- }
- },
- Scheme = "Bearer",
- BearerFormat = "JWT",
- In = ParameterLocation.Header,
- });
-
- // This adds the lock symbol next to every route in SwaggerUI.
- options.AddSecurityRequirement(new OpenApiSecurityRequirement
- {
- {
- new OpenApiSecurityScheme{ Reference = new OpenApiReference{ Type = ReferenceType.SecurityScheme, Id = bearerKey } },
- Array.Empty()
- }
- });
- });
- }
- }
+if (app.Environment.IsDevelopment())
+{
+ app.UseHttpsRedirection(); // Apache handles HTTPS in production.
}
+else
+{
+ app.UseResponseCompression();
+}
+app.UseSwagger();
+app.UseSwaggerUI(options => options.DocumentTitle = "Shogi.Api");
+app.UseAuthorization();
+app.Map("/", () => "OK");
+app.MapControllers();
+app.UseCors(policy =>
+{
+ policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
+});
+
+app.MapHub("/gamehub").RequireAuthorization();
+
+app.Run();
+
+static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager configuration)
+{
+ builder.Services
+ .AddAuthorizationBuilder()
+ .AddPolicy("Admin", policy =>
+ {
+ policy.RequireAuthenticatedUser();
+ policy.RequireAssertion(context => context.User?.Identity?.Name switch
+ {
+ "Hauth@live.com" => true,
+ "aat-account" => true,
+ _ => false
+ });
+ });
+
+ builder.Services
+ .AddDbContext(options =>
+ {
+ var cs = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database not configured.");
+ options.UseSqlServer(cs);
+
+ // This is helpful to debug account issues without affecting the database.
+ //options.UseInMemoryDatabase("AppDb");
+ })
+ .AddIdentityApiEndpoints(options =>
+ {
+ options.SignIn.RequireConfirmedEmail = true;
+ options.User.RequireUniqueEmail = true;
+ })
+ .AddEntityFrameworkStores();
+
+ // I shouldn't this because I have it above, right?
+ //builder.Services.Configure(options =>
+ //{
+ // options.SignIn.RequireConfirmedEmail = true;
+ // options.User.RequireUniqueEmail = true;
+ //});
+
+ builder.Services.ConfigureApplicationCookie(options =>
+ {
+ options.SlidingExpiration = true;
+ options.ExpireTimeSpan = TimeSpan.FromDays(3);
+ });
+
+}
\ No newline at end of file
diff --git a/Shogi.Api/Repositories/Dto/SessionDto.cs b/Shogi.Api/Repositories/Dto/SessionDto.cs
index 996b504..bec4145 100644
--- a/Shogi.Api/Repositories/Dto/SessionDto.cs
+++ b/Shogi.Api/Repositories/Dto/SessionDto.cs
@@ -1,5 +1,5 @@
namespace Shogi.Api.Repositories.Dto;
-public readonly record struct SessionDto(string Name, string Player1, string Player2)
+public readonly record struct SessionDto(string Id, string Player1Id, string Player2Id)
{
}
diff --git a/Shogi.Api/Repositories/EmailSender.cs b/Shogi.Api/Repositories/EmailSender.cs
new file mode 100644
index 0000000..d9e5c6d
--- /dev/null
+++ b/Shogi.Api/Repositories/EmailSender.cs
@@ -0,0 +1,54 @@
+using Microsoft.AspNetCore.Identity.UI.Services;
+using Microsoft.Extensions.Options;
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace Shogi.Api.Repositories;
+
+// https://app-smtp.brevo.com/real-time
+
+public class EmailSender : IEmailSender
+{
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
+ private readonly HttpClient client;
+ private string apiKey;
+
+
+ public EmailSender(HttpClient client, IOptionsMonitor apiKeys)
+ {
+ this.apiKey = apiKeys.CurrentValue.BrevoEmailService;
+ apiKeys.OnChange(keys => this.apiKey = keys.BrevoEmailService);
+ this.client = client;
+ }
+
+
+ public async Task SendEmailAsync(string email, string subject, string htmlMessage)
+ {
+ var body = new
+ {
+ Sender = new
+ {
+ Name = "Shogi Account Support",
+ Email = "shogi@lucaserver.space",
+ },
+ To = new[]
+ {
+ new
+ {
+ Name = email,
+ Email = email,
+ }
+ },
+ Subject = subject,
+ HtmlContent = htmlMessage,
+ };
+
+ var request = new HttpRequestMessage(HttpMethod.Post, new Uri("https://api.brevo.com/v3/smtp/email", UriKind.Absolute));
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ request.Headers.Add("api-key", apiKey);
+ request.Content = JsonContent.Create(body, options: Options);
+
+ var response = await this.client.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+ }
+}
diff --git a/Shogi.Api/Repositories/QueryRepository.cs b/Shogi.Api/Repositories/QueryRepository.cs
index 89cc942..6b489fb 100644
--- a/Shogi.Api/Repositories/QueryRepository.cs
+++ b/Shogi.Api/Repositories/QueryRepository.cs
@@ -1,65 +1,24 @@
using Dapper;
-using Shogi.Contracts.Api;
-using Shogi.Contracts.Types;
+using Shogi.Api.Repositories.Dto;
using System.Data;
using System.Data.SqlClient;
namespace Shogi.Api.Repositories;
-public class QueryRepository : IQueryRespository
+public class QueryRepository(IConfiguration configuration)
{
- private readonly string connectionString;
+ private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase")
+ ?? throw new InvalidOperationException("No database configured for QueryRepository.");
- public QueryRepository(IConfiguration configuration)
- {
- var connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("No database configured for QueryRepository.");
- this.connectionString = connectionString;
- }
+ public async Task> ReadSessionsMetadata(string playerId)
+ {
+ using var connection = new SqlConnection(this.connectionString);
- public async Task ReadSessionPlayerCount(string playerName)
- {
- using var connection = new SqlConnection(connectionString);
+ var results = await connection.QueryMultipleAsync(
+ "session.ReadSessionsMetadata",
+ new { PlayerId = playerId },
+ commandType: CommandType.StoredProcedure);
- var results = await connection.QueryMultipleAsync(
- "session.ReadSessionPlayerCount",
- new { PlayerName = playerName },
- commandType: System.Data.CommandType.StoredProcedure);
-
- var joinedSessions = await results.ReadAsync();
- var otherSessions = await results.ReadAsync();
- return new ReadSessionsPlayerCountResponse
- {
- PlayerHasJoinedSessions = joinedSessions.ToList(),
- AllOtherSessions = otherSessions.ToList()
- };
- }
-
- ///
- ///
- /// A with Item1 as player 1 and Item2 as player 2.
- public async Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName)
- {
- using var connection = new SqlConnection(connectionString);
- var results = await connection.QueryAsync<(string Player1Name, string Player1DisplayName, string Player2Name, string Player2DisplayName)>(
- "session.ReadUsersBySession",
- new { SessionName = sessionName },
- commandType: CommandType.StoredProcedure);
-
- if (results.Any())
- {
- var (Player1Name, Player1DisplayName, Player2Name, Player2DisplayName) = results.First();
- var p1 = new User(Player1Name, Player1DisplayName);
- var p2 = Player2Name != null
- ? new User(Player2Name, Player2DisplayName)
- : null;
- return (p1, p2);
- }
- return null;
- }
+ return await results.ReadAsync();
+ }
}
-
-public interface IQueryRespository
-{
- Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName);
- Task ReadSessionPlayerCount(string playerName);
-}
\ No newline at end of file
diff --git a/Shogi.Api/Repositories/SessionRepository.cs b/Shogi.Api/Repositories/SessionRepository.cs
index 3bdc0ae..0c6eaa3 100644
--- a/Shogi.Api/Repositories/SessionRepository.cs
+++ b/Shogi.Api/Repositories/SessionRepository.cs
@@ -1,120 +1,84 @@
using Dapper;
using Shogi.Api.Repositories.Dto;
using Shogi.Contracts.Api;
-using Shogi.Domain;
+using Shogi.Domain.Aggregates;
using System.Data;
using System.Data.SqlClient;
namespace Shogi.Api.Repositories;
-public class SessionRepository : ISessionRepository
+public class SessionRepository(IConfiguration configuration)
{
- private readonly string connectionString;
+ private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase")
+ ?? throw new InvalidOperationException("Database connection string not configured.");
- public SessionRepository(IConfiguration configuration)
- {
- connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database connection string not configured.");
- }
+ public async Task CreateSession(Session session)
+ {
+ using var connection = new SqlConnection(this.connectionString);
+ await connection.ExecuteAsync(
+ "session.CreateSession",
+ new
+ {
+ session.Id,
+ Player1Id = session.Player1,
+ },
+ commandType: CommandType.StoredProcedure);
+ }
- public async Task CreateSession(Session session)
- {
- using var connection = new SqlConnection(connectionString);
- await connection.ExecuteAsync(
- "session.CreateSession",
- new
- {
- session.Name,
- Player1Name = session.Player1,
- },
- commandType: CommandType.StoredProcedure);
- }
+ public async Task DeleteSession(string id)
+ {
+ using var connection = new SqlConnection(this.connectionString);
+ await connection.ExecuteAsync(
+ "session.DeleteSession",
+ new { Id = id },
+ commandType: CommandType.StoredProcedure);
+ }
- public async Task DeleteSession(string name)
- {
- using var connection = new SqlConnection(connectionString);
- await connection.ExecuteAsync(
- "session.DeleteSession",
- new { Name = name },
- commandType: CommandType.StoredProcedure);
- }
+ public async Task<(SessionDto? Session, IEnumerable Moves)> ReadSessionAndMoves(string id)
+ {
+ using var connection = new SqlConnection(this.connectionString);
+ var results = await connection.QueryMultipleAsync(
+ "session.ReadSession",
+ new { Id = id },
+ commandType: CommandType.StoredProcedure);
- public async Task ReadSession(string name)
- {
- using var connection = new SqlConnection(connectionString);
- var results = await connection.QueryMultipleAsync(
- "session.ReadSession",
- new { Name = name },
- commandType: CommandType.StoredProcedure);
+ var sessionDtos = await results.ReadAsync();
+ if (!sessionDtos.Any())
+ {
+ return (null, []);
+ }
- var sessionDtos = await results.ReadAsync();
- if (!sessionDtos.Any()) return null;
- var dto = sessionDtos.First();
- var session = new Session(dto.Name, dto.Player1);
- if (!string.IsNullOrWhiteSpace(dto.Player2)) session.AddPlayer2(dto.Player2);
+ var moveDtos = await results.ReadAsync();
- var moveDtos = await results.ReadAsync();
- foreach (var move in moveDtos)
- {
- if (move.PieceFromHand.HasValue)
- {
- session.Board.Move(move.PieceFromHand.Value, move.To);
- }
- else if (move.From != null)
- {
- session.Board.Move(move.From, move.To, false);
- }
- else
- {
- throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}");
- }
- }
- return session;
- }
+ return new(sessionDtos.First(), moveDtos);
+ }
- public async Task CreateMove(string sessionName, MovePieceCommand command)
- {
- var yep = new
- {
- command.To,
- command.From,
- command.IsPromotion,
- command.PieceFromHand,
- SessionName = sessionName
- };
+ public async Task CreateMove(string sessionId, MovePieceCommand command)
+ {
+ using var connection = new SqlConnection(this.connectionString);
+ await connection.ExecuteAsync(
+ "session.CreateMove",
+ new
+ {
+ command.To,
+ command.From,
+ command.IsPromotion,
+ PieceFromHand = command.PieceFromHand.ToString(),
+ SessionId = sessionId
+ },
+ commandType: CommandType.StoredProcedure);
+ }
- using var connection = new SqlConnection(connectionString);
- await connection.ExecuteAsync(
- "session.CreateMove",
- new
- {
- command.To,
- command.From,
- command.IsPromotion,
- PieceFromHand = command.PieceFromHand.ToString(),
- SessionName = sessionName
- },
- commandType: CommandType.StoredProcedure);
- }
-
- public async Task SetPlayer2(string sessionName, string player2Name)
- {
- using var connection = new SqlConnection(connectionString);
- await connection.ExecuteAsync(
- "session.SetPlayer2",
- new
- {
- SessionName = sessionName,
- Player2Name = player2Name
- },
- commandType: CommandType.StoredProcedure);
- }
-}
-
-public interface ISessionRepository
-{
- Task CreateMove(string sessionName, MovePieceCommand command);
- Task CreateSession(Session session);
- Task DeleteSession(string name);
- Task ReadSession(string name);
- Task SetPlayer2(string sessionName, string player2Name);
+ public async Task SetPlayer2(string sessionId, string player2Id)
+ {
+ using var connection = new SqlConnection(this.connectionString);
+ await connection.ExecuteAsync(
+ "session.SetPlayer2",
+ new
+ {
+ SessionId = sessionId,
+ PlayerId = player2Id
+ },
+ commandType: CommandType.StoredProcedure);
+ }
}
\ No newline at end of file
diff --git a/Shogi.Api/Repositories/UserRepository.cs b/Shogi.Api/Repositories/UserRepository.cs
deleted file mode 100644
index 9f54dbf..0000000
--- a/Shogi.Api/Repositories/UserRepository.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using Dapper;
-using Shogi.Api.Models;
-using System.Data;
-using System.Data.SqlClient;
-
-namespace Shogi.Api.Repositories;
-
-public class UserRepository : IUserRepository
-{
- private readonly string connectionString;
-
- public UserRepository(IConfiguration configuration)
- {
- var connectionString = configuration.GetConnectionString("ShogiDatabase");
- if (string.IsNullOrEmpty(connectionString))
- {
- throw new InvalidOperationException("Connection string for database is empty.");
- }
- this.connectionString = connectionString;
- }
-
- public async Task CreateUser(User user)
- {
- using var connection = new SqlConnection(connectionString);
- await connection.ExecuteAsync(
- "user.CreateUser",
- new
- {
- Name = user.Id,
- DisplayName = user.DisplayName,
- Platform = user.LoginPlatform.ToString()
- },
- commandType: CommandType.StoredProcedure);
- }
-
- public async Task ReadUser(string id)
- {
- using var connection = new SqlConnection(connectionString);
- var results = await connection.QueryAsync(
- "user.ReadUser",
- new { Name = id },
- commandType: CommandType.StoredProcedure);
-
- return results.FirstOrDefault();
- }
-}
-
-public interface IUserRepository
-{
- Task CreateUser(User user);
- Task ReadUser(string id);
-}
\ No newline at end of file
diff --git a/Shogi.Api/Services/SocketService.cs b/Shogi.Api/Services/SocketService.cs
deleted file mode 100644
index 3921202..0000000
--- a/Shogi.Api/Services/SocketService.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using FluentValidation;
-using Shogi.Contracts.Socket;
-using Shogi.Contracts.Types;
-using Shogi.Api.Extensions;
-using Shogi.Api.Managers;
-using System.Net;
-using System.Net.WebSockets;
-using System.Text.Json;
-
-namespace Shogi.Api.Services
-{
- public interface ISocketService
- {
- Task HandleSocketRequest(HttpContext context);
- }
-
- ///
- /// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages.
- ///
- public class SocketService : ISocketService
- {
- private readonly ILogger logger;
- private readonly ISocketConnectionManager communicationManager;
- private readonly ISocketTokenCache tokenManager;
-
- public SocketService(
- ILogger logger,
- ISocketConnectionManager communicationManager,
- ISocketTokenCache tokenManager) : base()
- {
- this.logger = logger;
- this.communicationManager = communicationManager;
- this.tokenManager = tokenManager;
- }
-
- public async Task HandleSocketRequest(HttpContext context)
- {
- if (!context.Request.Query.Keys.Contains("token"))
- {
- context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
- return;
- }
- var token = Guid.Parse(context.Request.Query["token"][0] ?? throw new InvalidOperationException("Token expected during socket connection request, but was not sent."));
- var userName = tokenManager.GetUsername(token);
-
- if (string.IsNullOrEmpty(userName))
- {
- context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
- return;
- }
- var socket = await context.WebSockets.AcceptWebSocketAsync();
-
- communicationManager.Subscribe(socket, userName);
- // TODO: I probably don't need this while-loop anymore? Perhaps unsubscribe when a disconnect is detected instead.
- while (socket.State.HasFlag(WebSocketState.Open))
- {
- try
- {
- var message = await socket.ReceiveTextAsync();
- if (string.IsNullOrWhiteSpace(message)) continue;
- logger.LogInformation("Request \n{0}\n", message);
- var request = JsonSerializer.Deserialize(message);
- if (request == null || !Enum.IsDefined(typeof(SocketAction), request.Action))
- {
- await socket.SendTextAsync("Error: Action not recognized.");
- continue;
- }
- switch (request.Action)
- {
- default:
- await socket.SendTextAsync($"Received your message with action {request.Action}, but did no work.");
- break;
- }
- }
- catch (OperationCanceledException ex)
- {
- logger.LogError(ex.Message);
- }
- catch (WebSocketException ex)
- {
- logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketConnectionManager)}.");
- logger.LogInformation("Probably tried writing to a closed socket.");
- logger.LogError(ex.Message);
- }
- communicationManager.Unsubscribe(userName);
-
- if (!socket.State.HasFlag(WebSocketState.Closed) && !socket.State.HasFlag(WebSocketState.Aborted))
- {
- try
- {
- await socket.CloseAsync(WebSocketCloseStatus.NormalClosure,
- "Socket closed",
- CancellationToken.None);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Ignored exception during socket closing. {ex.Message}");
- }
- }
-
- }
- }
- }
-}
diff --git a/Shogi.Api/Shogi.Api.csproj b/Shogi.Api/Shogi.Api.csproj
index e237ce2..2394d04 100644
--- a/Shogi.Api/Shogi.Api.csproj
+++ b/Shogi.Api/Shogi.Api.csproj
@@ -23,13 +23,15 @@
-
-
-
-
-
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/Shogi.Api/ShogiUserClaimsTransformer.cs b/Shogi.Api/ShogiUserClaimsTransformer.cs
deleted file mode 100644
index e94f46a..0000000
--- a/Shogi.Api/ShogiUserClaimsTransformer.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.Identity.Web;
-using Shogi.Api.Models;
-using Shogi.Api.Repositories;
-using System.Security.Claims;
-
-namespace Shogi.Api;
-
-///
-/// Standardizes the claims from third party issuers. Also registers new msal users in the database.
-///
-public class ShogiUserClaimsTransformer : IShogiUserClaimsTransformer
-{
- private readonly IUserRepository userRepository;
-
- public ShogiUserClaimsTransformer(IUserRepository userRepository)
- {
- this.userRepository = userRepository;
- }
-
- public async Task TransformAsync(ClaimsPrincipal principal)
- {
- var newPrincipal = IsMicrosoft(principal)
- ? await CreateClaimsFromMicrosoftPrincipal(principal)
- : await CreateClaimsFromGuestPrincipal(principal);
-
- return newPrincipal;
- }
-
- public async Task CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal)
- {
- var id = GetGuestUserId(principal);
- if (string.IsNullOrWhiteSpace(id))
- {
- var newUser = User.CreateGuestUser(Guid.NewGuid().ToString());
- await this.userRepository.CreateUser(newUser);
- return new ClaimsPrincipal(CreateClaimsIdentity(newUser));
- }
-
- var user = await this.userRepository.ReadUser(id);
- if (user == null) throw new UnauthorizedAccessException("Guest account does not exist.");
- return new ClaimsPrincipal(CreateClaimsIdentity(user));
- }
-
- private async Task CreateClaimsFromMicrosoftPrincipal(ClaimsPrincipal principal)
- {
- var id = GetMicrosoftUserId(principal);
- var displayname = principal.GetDisplayName();
- if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(displayname))
- {
- throw new UnauthorizedAccessException("Unknown claim set.");
- }
-
- var user = await this.userRepository.ReadUser(id);
- if (user == null)
- {
- user = User.CreateMsalUser(id, displayname);
- await this.userRepository.CreateUser(user);
- }
- return new ClaimsPrincipal(CreateClaimsIdentity(user));
-
- }
-
- private static bool IsMicrosoft(ClaimsPrincipal self)
- {
- return self.GetObjectId() != null;
- }
-
- private static string? GetMicrosoftUserId(ClaimsPrincipal self)
- {
- return self.GetObjectId();
- }
-
- private static string? GetGuestUserId(ClaimsPrincipal self)
- {
- return self.GetNameIdentifierId();
- }
-
- private static ClaimsIdentity CreateClaimsIdentity(User user)
- {
- var claims = new List(4)
- {
- new Claim(ClaimTypes.NameIdentifier, user.Id),
- new Claim(ClaimTypes.Name, user.DisplayName),
- };
- if (user.LoginPlatform == WhichLoginPlatform.Guest)
- {
-
- claims.Add(new Claim(ClaimTypes.Role, "Guest"));
- return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
- }
- else
- {
- return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
- }
- }
-}
-
-public interface IShogiUserClaimsTransformer : IClaimsTransformation
-{
- Task CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal);
-}
\ No newline at end of file
diff --git a/Shogi.Api/appsettings.Development.json b/Shogi.Api/appsettings.Development.json
index 8983e0f..47aef72 100644
--- a/Shogi.Api/appsettings.Development.json
+++ b/Shogi.Api/appsettings.Development.json
@@ -1,9 +1,13 @@
{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft": "Warning",
- "Microsoft.Hosting.Lifetime": "Information"
- }
- }
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "ApiKeys": {
+ "BrevoEmailService": "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"
+ },
+ "TestUserPassword": "I'mAToysRUsK1d"
}
diff --git a/Shogi.Api/appsettings.json b/Shogi.Api/appsettings.json
index b4da000..cc40092 100644
--- a/Shogi.Api/appsettings.json
+++ b/Shogi.Api/appsettings.json
@@ -1,29 +1,28 @@
{
- "ConnectionStrings": {
- "ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
- },
- "Logging": {
- "LogLevel": {
- "Default": "Warning",
- "Microsoft": "Warning",
- "Microsoft.Hosting.Lifetime": "Error",
- "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
- "System.Net.Http.HttpClient": "Error"
- }
- },
- "AzureAd": {
- "Instance": "https://login.microsoftonline.com/",
- "TenantId": "common",
- "ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff",
- "SwaggerUIClientId": "26bf69a4-2af8-4711-bf5b-79f75e20b082",
- "Scope": "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope"
- },
- "Cors": {
- "AllowedOrigins": [
- "http://localhost:3000",
- "https://localhost:3000",
- "https://api.lucaserver.space",
- "https://lucaserver.space"
- ]
- }
+ "ConnectionStrings": {
+ "ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Error",
+ "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
+ "System.Net.Http.HttpClient": "Error"
+ //"Microsoft.AspNetCore.SignalR": "Debug",
+ //"Microsoft.AspNetCore.Http.Connections": "Debug"
+ }
+ },
+ "ApiKeys": {
+ "BrevoEmailService": ""
+ },
+ "Cors": {
+ "AllowedOrigins": [
+ "http://localhost:3000",
+ "https://localhost:3000",
+ "https://api.lucaserver.space",
+ "https://lucaserver.space"
+ ]
+ },
+ "TestUserPassword": ""
}
\ No newline at end of file
diff --git a/Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs b/Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs
deleted file mode 100644
index c72d12a..0000000
--- a/Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using System;
-
-namespace Shogi.Contracts.Api;
-
- public class CreateGuestTokenResponse
- {
- public string UserId { get; }
- public string DisplayName { get; }
- public Guid OneTimeToken { get; }
-
- public CreateGuestTokenResponse(string userId, string displayName, Guid oneTimeToken)
- {
- UserId = userId;
- DisplayName = displayName;
- OneTimeToken = oneTimeToken;
- }
- }
diff --git a/Shogi.Contracts/Api/Commands/CreateSessionCommand.cs b/Shogi.Contracts/Api/Commands/CreateSessionCommand.cs
deleted file mode 100644
index 98a33f6..0000000
--- a/Shogi.Contracts/Api/Commands/CreateSessionCommand.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace Shogi.Contracts.Api;
-
-public class CreateSessionCommand
-{
- [Required]
- public string Name { get; set; }
-}
diff --git a/Shogi.Contracts/Api/Commands/CreateTokenResponse.cs b/Shogi.Contracts/Api/Commands/CreateTokenResponse.cs
deleted file mode 100644
index eb141dc..0000000
--- a/Shogi.Contracts/Api/Commands/CreateTokenResponse.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using System;
-
-namespace Shogi.Contracts.Api;
-
-public class CreateTokenResponse
-{
- public string UserId { get; set; }
- public string DisplayName { get; set; }
- public Guid OneTimeToken { get; set; }
-}
diff --git a/Shogi.Contracts/Api/Commands/MovePieceCommand.cs b/Shogi.Contracts/Api/Commands/MovePieceCommand.cs
index 3854113..7e77549 100644
--- a/Shogi.Contracts/Api/Commands/MovePieceCommand.cs
+++ b/Shogi.Contracts/Api/Commands/MovePieceCommand.cs
@@ -12,7 +12,7 @@ public class MovePieceCommand : IValidatableObject
///
public MovePieceCommand()
{
- To = string.Empty;
+ this.To = string.Empty;
}
///
@@ -20,9 +20,9 @@ public class MovePieceCommand : IValidatableObject
///
public MovePieceCommand(string from, string to, bool isPromotion)
{
- From = from;
- To = to;
- IsPromotion = isPromotion;
+ this.From = from;
+ this.To = to;
+ this.IsPromotion = isPromotion;
}
///
@@ -30,8 +30,8 @@ public class MovePieceCommand : IValidatableObject
///
public MovePieceCommand(WhichPiece pieceFromHand, string to)
{
- PieceFromHand = pieceFromHand;
- To = to;
+ this.PieceFromHand = pieceFromHand;
+ this.To = to;
}
///
@@ -57,21 +57,21 @@ public class MovePieceCommand : IValidatableObject
public IEnumerable Validate(ValidationContext validationContext)
{
- if (PieceFromHand.HasValue && !string.IsNullOrWhiteSpace(From))
+ if (this.PieceFromHand.HasValue && !string.IsNullOrWhiteSpace(this.From))
{
- yield return new ValidationResult($"{nameof(PieceFromHand)} and {nameof(From)} are mutually exclusive properties.");
+ yield return new ValidationResult($"{nameof(this.PieceFromHand)} and {nameof(this.From)} are mutually exclusive properties.");
}
- if (PieceFromHand.HasValue && IsPromotion.HasValue)
+ if (this.PieceFromHand.HasValue && this.IsPromotion.HasValue)
{
- yield return new ValidationResult($"{nameof(PieceFromHand)} and {nameof(IsPromotion)} are mutually exclusive properties.");
+ yield return new ValidationResult($"{nameof(this.PieceFromHand)} and {nameof(this.IsPromotion)} are mutually exclusive properties.");
}
- if (!Regex.IsMatch(To, "[A-I][1-9]"))
+ if (!Regex.IsMatch(this.To, "[A-I][1-9]"))
{
- yield return new ValidationResult($"{nameof(To)} must be a valid board position, between A1 and I9");
+ yield return new ValidationResult($"{nameof(this.To)} must be a valid board position, between A1 and I9");
}
- if (!string.IsNullOrEmpty(From) && !Regex.IsMatch(From, "[A-I][1-9]"))
+ if (!string.IsNullOrEmpty(this.From) && !Regex.IsMatch(this.From, "[A-I][1-9]"))
{
- yield return new ValidationResult($"{nameof(From)} must be a valid board position, between A1 and I9");
+ yield return new ValidationResult($"{nameof(this.From)} must be a valid board position, between A1 and I9");
}
}
}
diff --git a/Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs b/Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs
deleted file mode 100644
index 1e98968..0000000
--- a/Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using Shogi.Contracts.Types;
-using System.Collections.Generic;
-
-namespace Shogi.Contracts.Api;
-
-public class ReadSessionsPlayerCountResponse
- {
- public IList PlayerHasJoinedSessions { get; set; }
- public IList AllOtherSessions { get; set; }
- }
diff --git a/Shogi.Contracts/Api/Queries/ReadSessionResponse.cs b/Shogi.Contracts/Api/Queries/ReadSessionResponse.cs
deleted file mode 100644
index e8300ab..0000000
--- a/Shogi.Contracts/Api/Queries/ReadSessionResponse.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using Shogi.Contracts.Types;
-
-namespace Shogi.Contracts.Api;
-
-public class ReadSessionResponse
-{
- public Session Session { get; set; }
-}
diff --git a/Shogi.Contracts/Shogi.Contracts.csproj b/Shogi.Contracts/Shogi.Contracts.csproj
index 14dcce0..7b85818 100644
--- a/Shogi.Contracts/Shogi.Contracts.csproj
+++ b/Shogi.Contracts/Shogi.Contracts.csproj
@@ -10,4 +10,8 @@
Contains DTOs use for http requests to Shogi backend services.
+
+
+
+
diff --git a/Shogi.Contracts/Socket/ISocketMessage.cs b/Shogi.Contracts/Socket/ISocketMessage.cs
deleted file mode 100644
index 675d1fe..0000000
--- a/Shogi.Contracts/Socket/ISocketMessage.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using Shogi.Contracts.Types;
-
-namespace Shogi.Contracts.Socket;
-
-public interface ISocketMessage
-{
- SocketAction Action { get; }
-}
-
-public class SocketResponse : ISocketMessage
-{
- public SocketAction Action { get; set; }
-}
diff --git a/Shogi.Contracts/Socket/PlayerHasMovedMessage.cs b/Shogi.Contracts/Socket/PlayerHasMovedMessage.cs
deleted file mode 100644
index 048ad9e..0000000
--- a/Shogi.Contracts/Socket/PlayerHasMovedMessage.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Shogi.Contracts.Types;
-
-namespace Shogi.Contracts.Socket;
-
-public class PlayerHasMovedMessage : ISocketMessage
-{
- public SocketAction Action { get; }
- public string SessionName { get; set; }
- ///
- /// The player that made the move.
- ///
- public string PlayerName { get; set; }
-
- public PlayerHasMovedMessage()
- {
- Action = SocketAction.PieceMoved;
- SessionName = string.Empty;
- PlayerName = string.Empty;
- }
-}
diff --git a/Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs b/Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs
deleted file mode 100644
index 4700a9d..0000000
--- a/Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using Shogi.Contracts.Types;
-
-namespace Shogi.Contracts.Socket;
-
-public class SessionCreatedSocketMessage : ISocketMessage
-{
- public SocketAction Action => SocketAction.SessionCreated;
-}
diff --git a/Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs b/Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs
deleted file mode 100644
index 0457226..0000000
--- a/Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using Shogi.Contracts.Types;
-
-namespace Shogi.Contracts.Socket;
-
-public class SessionJoinedByPlayerSocketMessage : ISocketMessage
-{
- public SocketAction Action => SocketAction.SessionJoined;
-
- public string SessionName { get; set; }
-
- public SessionJoinedByPlayerSocketMessage(string sessionName)
- {
- SessionName = sessionName;
- }
-}
diff --git a/Shogi.Contracts/Types/Session.cs b/Shogi.Contracts/Types/Session.cs
index a6676aa..8f83cd5 100644
--- a/Shogi.Contracts/Types/Session.cs
+++ b/Shogi.Contracts/Types/Session.cs
@@ -1,9 +1,20 @@
-namespace Shogi.Contracts.Types;
+using System;
+
+namespace Shogi.Contracts.Types;
public class Session
{
- public User Player1 { get; set; }
- public User? Player2 { get; set; }
- public string SessionName { get; set; }
+ ///
+ /// Email
+ ///
+ public string Player1 { get; set; }
+
+ ///
+ /// Email. Null if no second player exists.
+ ///
+ public string? Player2 { get; set; }
+
+ public Guid SessionId { get; set; }
+
public BoardState BoardState { get; set; }
}
diff --git a/Shogi.Contracts/Types/SessionMetadata.cs b/Shogi.Contracts/Types/SessionMetadata.cs
index 0a00b2d..14563f9 100644
--- a/Shogi.Contracts/Types/SessionMetadata.cs
+++ b/Shogi.Contracts/Types/SessionMetadata.cs
@@ -1,8 +1,11 @@
-namespace Shogi.Contracts.Types
+using System;
+
+namespace Shogi.Contracts.Types
{
- public class SessionMetadata
- {
- public string Name { get; set; }
- public int PlayerCount { get; set; }
- }
+ public class SessionMetadata
+ {
+ public Guid SessionId { get; set; }
+ public string Player1 { get; set; } = string.Empty;
+ public string Player2 { get; set; } = string.Empty;
+ }
}
diff --git a/Shogi.Contracts/Types/SocketAction.cs b/Shogi.Contracts/Types/SocketAction.cs
deleted file mode 100644
index 52efc3a..0000000
--- a/Shogi.Contracts/Types/SocketAction.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace Shogi.Contracts.Types
-{
- public enum SocketAction
- {
- SessionCreated,
- SessionJoined,
- PieceMoved
- }
-}
diff --git a/Shogi.Contracts/Types/User.cs b/Shogi.Contracts/Types/User.cs
deleted file mode 100644
index 2cfc70b..0000000
--- a/Shogi.Contracts/Types/User.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace Shogi.Contracts.Types;
-
-public class User
-{
- public string Id { get; set; } = string.Empty;
-
- ///
- /// A display name for the user.
- ///
- public string Name { get; set; } = string.Empty;
-
- public User(string id, string name)
- {
- Id = id;
- Name = name;
- }
-}
diff --git a/Shogi.Database/AspNetUsersId.sql b/Shogi.Database/AspNetUsersId.sql
new file mode 100644
index 0000000..65a7f13
--- /dev/null
+++ b/Shogi.Database/AspNetUsersId.sql
@@ -0,0 +1,3 @@
+-- This is so I don't have to remember the type used in the dbo.AspNetUsers table for the Id column.
+CREATE TYPE [dbo].[AspNetUsersId]
+ FROM NVARCHAR(450) NOT NULL;
diff --git a/Shogi.Database/FirstTimeSetup.sql b/Shogi.Database/FirstTimeSetup.sql
index ed1a8d6..40422fe 100644
--- a/Shogi.Database/FirstTimeSetup.sql
+++ b/Shogi.Database/FirstTimeSetup.sql
@@ -4,4 +4,11 @@
--CREATE ROLE db_executor
--GRANT EXECUTE To db_executor
--- Give Shogi.Api user permission to db_executor, db_datareader, db_datawriter
\ No newline at end of file
+-- Give Shogi.Api user permission to db_executor, db_datareader, db_datawriter
+
+
+/**
+* Local setup instructions, in order:
+* 1. To setup the Shogi database, use the dacpac process in visual studio with the Shogi.Database project.
+* 2. To setup the Entity Framework users database, run this powershell command using Shogi.Api as the target project: dotnet ef database update
+*/
\ No newline at end of file
diff --git a/Shogi.Database/Post Deployment/Script.PostDeployment.sql b/Shogi.Database/Post Deployment/Script.PostDeployment.sql
index dd53baf..bf58235 100644
--- a/Shogi.Database/Post Deployment/Script.PostDeployment.sql
+++ b/Shogi.Database/Post Deployment/Script.PostDeployment.sql
@@ -10,6 +10,5 @@ Post-Deployment Script Template
--------------------------------------------------------------------------------------
*/
-:r .\Scripts\PopulateLoginPlatforms.sql
:r .\Scripts\PopulatePieces.sql
:r .\Scripts\EnableSnapshotIsolationLevel.sql
\ No newline at end of file
diff --git a/Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql b/Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql
deleted file mode 100644
index 044b8ba..0000000
--- a/Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql
+++ /dev/null
@@ -1,16 +0,0 @@
-
-DECLARE @LoginPlatforms TABLE (
- [Platform] NVARCHAR(20)
-)
-
-INSERT INTO @LoginPlatforms ([Platform])
-VALUES
- ('Guest'),
- ('Microsoft');
-
-MERGE [user].[LoginPlatform] as t
-USING @LoginPlatforms as s
-ON t.[Platform] = s.[Platform]
-WHEN NOT MATCHED THEN
- INSERT ([Platform])
- VALUES (s.[Platform]);
\ No newline at end of file
diff --git a/Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql b/Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql
new file mode 100644
index 0000000..9f8d0f2
--- /dev/null
+++ b/Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql
@@ -0,0 +1,18 @@
+CREATE FUNCTION [session].[MaxNewSessionsPerUser]() RETURNS INT
+AS
+BEGIN
+
+ DECLARE @MaxNewSessionsCreatedByAnyOneUser INT;
+
+ WITH CountOfNewSessionsPerPlayer AS
+ (
+ SELECT COUNT(*) as TotalNewSessions
+ FROM [session].[Session]
+ WHERE Player2Id IS NULL
+ GROUP BY Player1Id
+ )
+ SELECT @MaxNewSessionsCreatedByAnyOneUser = MAX(CountOfNewSessionsPerPlayer.TotalNewSessions)
+ FROM CountOfNewSessionsPerPlayer
+
+ RETURN @MaxNewSessionsCreatedByAnyOneUser
+END
diff --git a/Shogi.Database/Session/Stored Procedures/CreateMove.sql b/Shogi.Database/Session/Stored Procedures/CreateMove.sql
index 6125e59..946ab94 100644
--- a/Shogi.Database/Session/Stored Procedures/CreateMove.sql
+++ b/Shogi.Database/Session/Stored Procedures/CreateMove.sql
@@ -1,9 +1,9 @@
CREATE PROCEDURE [session].[CreateMove]
- @To VARCHAR(2),
- @From VARCHAR(2) = NULL,
+ @To VARCHAR(2),
+ @From VARCHAR(2) = NULL,
@IsPromotion BIT = 0,
- @PieceFromHand NVARCHAR(13) = NULL,
- @SessionName [session].[SessionName]
+ @PieceFromHand NVARCHAR(13) = NULL,
+ @SessionId [session].[SessionSurrogateKey]
AS
BEGIN
@@ -13,11 +13,6 @@ BEGIN
BEGIN TRANSACTION
- DECLARE @SessionId BIGINT = 0;
- SELECT @SessionId = Id
- FROM [session].[Session]
- WHERE [Name] = @SessionName;
-
DECLARE @PieceIdFromhand INT = NULL;
SELECT @PieceIdFromhand = Id
FROM [session].[Piece]
diff --git a/Shogi.Database/Session/Stored Procedures/CreateSession.sql b/Shogi.Database/Session/Stored Procedures/CreateSession.sql
index 879f2ab..3331682 100644
--- a/Shogi.Database/Session/Stored Procedures/CreateSession.sql
+++ b/Shogi.Database/Session/Stored Procedures/CreateSession.sql
@@ -1,12 +1,13 @@
CREATE PROCEDURE [session].[CreateSession]
- @Name [session].[SessionName],
- @Player1Name [user].[UserName]
+ @Id [session].[SessionSurrogateKey],
+ @Player1Id [dbo].[AspNetUsersId]
AS
BEGIN
SET NOCOUNT ON
- INSERT INTO [session].[Session] ([Name], Player1Id)
- SELECT @Name, Id
- FROM [user].[User]
- WHERE [Name] = @Player1Name
+ INSERT INTO [session].[Session]
+ ([Id], Player1Id)
+ VALUES
+ (@Id, @Player1Id)
+
END
\ No newline at end of file
diff --git a/Shogi.Database/Session/Stored Procedures/DeleteSession.sql b/Shogi.Database/Session/Stored Procedures/DeleteSession.sql
index 38cace2..45dbb08 100644
--- a/Shogi.Database/Session/Stored Procedures/DeleteSession.sql
+++ b/Shogi.Database/Session/Stored Procedures/DeleteSession.sql
@@ -1,5 +1,5 @@
CREATE PROCEDURE [session].[DeleteSession]
- @Name [session].[SessionName]
+ @Id [session].[SessionSurrogateKey]
AS
-DELETE FROM [session].[Session] WHERE [Name] = @Name;
\ No newline at end of file
+DELETE FROM [session].[Session] WHERE [Id] = @Id;
\ No newline at end of file
diff --git a/Shogi.Database/Session/Stored Procedures/ReadSession.sql b/Shogi.Database/Session/Stored Procedures/ReadSession.sql
index f37abe8..c9670ec 100644
--- a/Shogi.Database/Session/Stored Procedures/ReadSession.sql
+++ b/Shogi.Database/Session/Stored Procedures/ReadSession.sql
@@ -1,5 +1,5 @@
CREATE PROCEDURE [session].[ReadSession]
- @Name [session].[SessionName]
+ @Id [session].[SessionSurrogateKey]
AS
BEGIN
SET NOCOUNT ON -- Performance boost
@@ -10,13 +10,12 @@ BEGIN
-- Session
SELECT
- sess.[Name],
- p1.[Name] as Player1,
- p2.[Name] as Player2
- FROM [session].[Session] sess
- INNER JOIN [user].[User] p1 on sess.Player1Id = p1.Id
- LEFT JOIN [user].[User] p2 on sess.Player2Id = p2.Id
- WHERE sess.[Name] = @Name;
+ Id,
+ Player1Id,
+ Player2Id,
+ CreatedDate
+ FROM [session].[Session]
+ WHERE Id = @Id;
-- Player moves
SELECT
@@ -27,7 +26,7 @@ BEGIN
FROM [session].[Move] mv
INNER JOIN [session].[Session] sess ON sess.Id = mv.SessionId
LEFT JOIN [session].Piece piece on piece.Id = mv.PieceIdFromHand
- WHERE sess.[Name] = @Name;
+ WHERE sess.[Id] = @Id;
COMMIT
END
diff --git a/Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql b/Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql
deleted file mode 100644
index c993332..0000000
--- a/Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql
+++ /dev/null
@@ -1,31 +0,0 @@
-CREATE PROCEDURE [session].[ReadSessionPlayerCount]
- @PlayerName [user].UserName
-AS
-BEGIN
- SET NOCOUNT ON;
-
- DECLARE @PlayerId as BIGINT;
- SELECT @PlayerId = Id
- FROM [user].[User]
- WHERE [Name] = @PlayerName;
-
- -- Result set of sessions which @PlayerName participates in.
- SELECT
- [Name],
- CASE
- WHEN Player2Id IS NULL THEN 1
- ELSE 2
- END AS PlayerCount
- FROM [session].[Session]
- WHERE Player1Id = @PlayerId OR Player2Id = @PlayerId;
-
- -- Result set of sessions which @PlayerName does not participate in.
- SELECT
- [Name],
- CASE
- WHEN Player2Id IS NULL THEN 1
- ELSE 2
- END AS PlayerCount
- FROM [session].[Session]
- WHERE Player1Id <> @PlayerId AND ISNULL(Player2Id, 0) <> @PlayerId;
-END
\ No newline at end of file
diff --git a/Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql b/Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql
new file mode 100644
index 0000000..bff7ae9
--- /dev/null
+++ b/Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql
@@ -0,0 +1,21 @@
+CREATE PROCEDURE [session].[ReadSessionsMetadata]
+ @PlayerId [dbo].[AspNetUsersId]
+AS
+BEGIN
+ SET NOCOUNT ON;
+
+ -- Read all sessions, in this order:
+ -- 1. sessions created by the logged-in user
+ -- 2. any other sessions the logged-in user participates in
+ -- 3. all other sessions
+ SELECT
+ Id, Player1Id, Player2Id, [Session].CreatedDate,
+ case
+ when Player1Id = @PlayerId then 0
+ when Player2Id = @PlayerId then 1
+ else 2
+ end as OrderBy
+ FROM [session].[Session]
+ Order By OrderBy ASC, CreatedDate DESC
+
+END
\ No newline at end of file
diff --git a/Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql b/Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql
deleted file mode 100644
index d8c6ed7..0000000
--- a/Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql
+++ /dev/null
@@ -1,13 +0,0 @@
-CREATE PROCEDURE [session].[ReadUsersBySession]
- @SessionName [session].[SessionName]
-AS
-
-SELECT
- p1.[Name] as Player1Name,
- p1.DisplayName as Player1DisplayName,
- p2.[Name] as Player2Name,
- p2.DisplayName as Player2Displayname
-FROM [session].[Session] sess
- INNER JOIN [user].[User] p1 ON sess.Player1Id = p1.Id
- LEFT JOIN [user].[User] p2 on sess.Player2Id = p2.Id
-WHERE sess.[Name] = @SessionName;
diff --git a/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql b/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql
index afad1c2..050a415 100644
--- a/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql
+++ b/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql
@@ -1,16 +1,13 @@
CREATE PROCEDURE [session].[SetPlayer2]
- @SessionName [session].[SessionName],
- @Player2Name [user].[UserName] NULL
+ @SessionId [session].[SessionSurrogateKey],
+ @PlayerId [dbo].[AspNetUsersId]
AS
BEGIN
SET NOCOUNT ON;
-
- DECLARE @player2Id BIGINT;
- SELECT @player2Id = Id FROM [user].[User] WHERE [Name] = @Player2Name;
-
- UPDATE sess
- SET Player2Id = @player2Id
- FROM [session].[Session] sess
- WHERE sess.[Name] = @SessionName;
+
+ UPDATE [session].[Session]
+ SET Player2Id = @PlayerId
+ FROM [session].[Session]
+ WHERE Id = @SessionId;
END
diff --git a/Shogi.Database/Session/Tables/Move.sql b/Shogi.Database/Session/Tables/Move.sql
index f5b00f6..549bf4a 100644
--- a/Shogi.Database/Session/Tables/Move.sql
+++ b/Shogi.Database/Session/Tables/Move.sql
@@ -1,11 +1,11 @@
CREATE TABLE [session].[Move]
(
- [Id] INT NOT NULL PRIMARY KEY IDENTITY,
- [SessionId] BIGINT NOT NULL,
- [To] VARCHAR(2) NOT NULL,
- [From] VARCHAR(2) NULL,
+ [Id] INT NOT NULL PRIMARY KEY IDENTITY,
+ [SessionId] [session].[SessionSurrogateKey] NOT NULL,
+ [To] VARCHAR(2) NOT NULL,
+ [From] VARCHAR(2) NULL,
[PieceIdFromHand] INT NULL,
- [IsPromotion] BIT DEFAULT 0
+ [IsPromotion] BIT DEFAULT 0
CONSTRAINT [Cannot end where you start]
CHECK ([From] <> [To]),
diff --git a/Shogi.Database/Session/Tables/Session.sql b/Shogi.Database/Session/Tables/Session.sql
index 889e61e..c35f1da 100644
--- a/Shogi.Database/Session/Tables/Session.sql
+++ b/Shogi.Database/Session/Tables/Session.sql
@@ -1,16 +1,8 @@
CREATE TABLE [session].[Session]
(
- Id BIGINT NOT NULL PRIMARY KEY IDENTITY,
- [Name] [session].[SessionName] UNIQUE,
- Player1Id BIGINT NOT NULL,
- Player2Id BIGINT NULL,
- Created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
-
- CONSTRAINT FK_Player1_User FOREIGN KEY (Player1Id) REFERENCES [user].[User] (Id)
- ON DELETE CASCADE
- ON UPDATE CASCADE,
-
- CONSTRAINT FK_Player2_User FOREIGN KEY (Player2Id) REFERENCES [user].[User] (Id)
- ON DELETE NO ACTION
- ON UPDATE NO ACTION
+ Id [session].[SessionSurrogateKey] PRIMARY KEY,
+ Player1Id [dbo].[AspNetUsersId] NOT NULL,
+ Player2Id [dbo].[AspNetUsersId] NULL,
+ [CreatedDate] DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
+ CONSTRAINT [CK_Session_LimitedNewSessions] CHECK ([session].MaxNewSessionsPerUser() < 4),
)
diff --git a/Shogi.Database/Session/Types/SessionName.sql b/Shogi.Database/Session/Types/SessionName.sql
deleted file mode 100644
index 0e6f505..0000000
--- a/Shogi.Database/Session/Types/SessionName.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-CREATE TYPE [session].[SessionName]
- FROM nvarchar(50) NOT NULL
diff --git a/Shogi.Database/Session/Types/SessionSurrogateKey.sql b/Shogi.Database/Session/Types/SessionSurrogateKey.sql
new file mode 100644
index 0000000..a10a0f4
--- /dev/null
+++ b/Shogi.Database/Session/Types/SessionSurrogateKey.sql
@@ -0,0 +1,2 @@
+CREATE TYPE [session].[SessionSurrogateKey]
+ FROM CHAR(36) NOT NULL
diff --git a/Shogi.Database/Shogi.Database.refactorlog b/Shogi.Database/Shogi.Database.refactorlog
new file mode 100644
index 0000000..63da8ad
--- /dev/null
+++ b/Shogi.Database/Shogi.Database.refactorlog
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Shogi.Database/Shogi.Database.sqlproj b/Shogi.Database/Shogi.Database.sqlproj
index e2c8d6b..26024ad 100644
--- a/Shogi.Database/Shogi.Database.sqlproj
+++ b/Shogi.Database/Shogi.Database.sqlproj
@@ -58,36 +58,27 @@
-
-
-
-
+
-
-
-
-
-
-
-
-
-
+
-
+
+
+
@@ -97,4 +88,7 @@
+
+
+
\ No newline at end of file
diff --git a/Shogi.Database/User/StoredProcedures/CreateUser.sql b/Shogi.Database/User/StoredProcedures/CreateUser.sql
deleted file mode 100644
index d86ac22..0000000
--- a/Shogi.Database/User/StoredProcedures/CreateUser.sql
+++ /dev/null
@@ -1,14 +0,0 @@
-CREATE PROCEDURE [user].[CreateUser]
- @Name [user].[UserName],
- @DisplayName NVARCHAR(100),
- @Platform NVARCHAR(20)
-AS
-BEGIN
-
-SET NOCOUNT ON
-
-INSERT INTO [user].[User] ([Name], DisplayName, [Platform])
-VALUES
- (@Name, @DisplayName, @Platform);
-
-END
\ No newline at end of file
diff --git a/Shogi.Database/User/StoredProcedures/ReadUser.sql b/Shogi.Database/User/StoredProcedures/ReadUser.sql
deleted file mode 100644
index 13e0a10..0000000
--- a/Shogi.Database/User/StoredProcedures/ReadUser.sql
+++ /dev/null
@@ -1,11 +0,0 @@
-CREATE PROCEDURE [user].[ReadUser]
- @Name [user].[UserName]
-AS
-BEGIN
- SELECT
- [Name] as Id,
- DisplayName,
- [Platform]
- FROM [user].[User]
- WHERE [Name] = @Name;
-END
\ No newline at end of file
diff --git a/Shogi.Database/User/Tables/LoginPlatform.sql b/Shogi.Database/User/Tables/LoginPlatform.sql
deleted file mode 100644
index e63d0ca..0000000
--- a/Shogi.Database/User/Tables/LoginPlatform.sql
+++ /dev/null
@@ -1,4 +0,0 @@
-CREATE TABLE [user].[LoginPlatform]
-(
- [Platform] NVARCHAR(20) NOT NULL PRIMARY KEY
-)
diff --git a/Shogi.Database/User/Tables/User.sql b/Shogi.Database/User/Tables/User.sql
deleted file mode 100644
index f0915f9..0000000
--- a/Shogi.Database/User/Tables/User.sql
+++ /dev/null
@@ -1,12 +0,0 @@
-CREATE TABLE [user].[User]
-(
- [Id] BIGINT NOT NULL PRIMARY KEY IDENTITY, -- TODO: Consider using user.UserName as the PK to avoid confusing "Id" in the database vs "Id" in the domain model.
- [Name] [user].[UserName] NOT NULL UNIQUE,
- [DisplayName] NVARCHAR(100) NOT NULL,
- [Platform] NVARCHAR(20) NOT NULL,
- [CreatedDate] DATETIMEOFFSET DEFAULT SYSDATETIMEOFFSET()
-
- CONSTRAINT User_Platform FOREIGN KEY ([Platform]) References [user].[LoginPlatform] ([Platform])
- ON DELETE CASCADE
- ON UPDATE CASCADE
-)
diff --git a/Shogi.Database/User/Types/UserName.sql b/Shogi.Database/User/Types/UserName.sql
deleted file mode 100644
index 224d537..0000000
--- a/Shogi.Database/User/Types/UserName.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-CREATE TYPE [user].[UserName]
- FROM nvarchar(100) NOT NULL
diff --git a/Shogi.Database/User/User.sql b/Shogi.Database/User/User.sql
deleted file mode 100644
index 08baf83..0000000
--- a/Shogi.Database/User/User.sql
+++ /dev/null
@@ -1 +0,0 @@
-CREATE SCHEMA [user]
diff --git a/Shogi.Domain/Aggregates/Session.cs b/Shogi.Domain/Aggregates/Session.cs
index 6ba0e35..4c76fcf 100644
--- a/Shogi.Domain/Aggregates/Session.cs
+++ b/Shogi.Domain/Aggregates/Session.cs
@@ -1,41 +1,32 @@
using Shogi.Domain.ValueObjects;
-namespace Shogi.Domain;
+namespace Shogi.Domain.Aggregates;
-public class Session
+public class Session(Guid id, string player1Name)
{
- public Session(
- string name,
- string player1Name)
- {
- Name = name;
- Player1 = player1Name;
- Board = new(BoardState.StandardStarting);
- }
+ public Guid Id { get; } = id;
- public string Name { get; }
+ public ShogiBoard Board { get; } = new(BoardState.StandardStarting);
- public ShogiBoard Board { get; }
+ ///
+ /// The email of the player which created the session.
+ ///
+ public string Player1 { get; } = player1Name;
- ///
- /// The User.Id of the player which created the session.
- ///
- public string Player1 { get; }
+ ///
+ /// The email of the second player.
+ ///
+ public string? Player2 { get; private set; }
- ///
- /// The User.Id of the second player.
- ///
- public string? Player2 { get; private set; }
+ public void AddPlayer2(string player2Name)
+ {
+ if (this.Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player.");
+ if (this.Player1.Equals(player2Name, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Player 2 must be different from Player 1");
+ this.Player2 = player2Name;
+ }
- public void AddPlayer2(string player2Name)
- {
- if (Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player.");
- if (Player1.Equals(player2Name, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Player 2 must be different from Player 1");
- Player2 = player2Name;
- }
-
- public bool IsSeated(string playerName)
- {
- return Player1 == playerName || Player2 == playerName;
- }
+ public bool IsSeated(string playerName)
+ {
+ return this.Player1 == playerName || this.Player2 == playerName;
+ }
}
diff --git a/Shogi.UI/App.razor b/Shogi.UI/App.razor
index efa8cea..e7359b7 100644
--- a/Shogi.UI/App.razor
+++ b/Shogi.UI/App.razor
@@ -2,7 +2,7 @@
-
+
Not found
diff --git a/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
new file mode 100644
index 0000000..4234d33
--- /dev/null
+++ b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
@@ -0,0 +1,246 @@
+namespace Shogi.UI.Identity;
+
+using Microsoft.AspNetCore.Components.Authorization;
+using System.Net.Http.Json;
+using System.Security.Claims;
+using System.Text;
+using System.Text.Json;
+
+///
+/// Handles state for cookie-based auth.
+///
+public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement
+{
+ ///
+ /// Map the JavaScript-formatted properties to C#-formatted classes.
+ ///
+ private readonly JsonSerializerOptions jsonSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ ///
+ /// Special auth client.
+ ///
+ private readonly HttpClient _httpClient;
+
+ ///
+ /// Authentication state.
+ ///
+ private bool _authenticated = false;
+
+ ///
+ /// Default principal for anonymous (not authenticated) users.
+ ///
+ private readonly ClaimsPrincipal Unauthenticated =
+ new(new ClaimsIdentity());
+
+ ///
+ /// Create a new instance of the auth provider.
+ ///
+ /// Factory to retrieve auth client.
+ public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory)
+ => _httpClient = httpClientFactory.CreateClient("Auth");
+
+ ///
+ /// Register a new user.
+ ///
+ /// The user's email address.
+ /// The user's password.
+ /// The result serialized to a .
+ ///
+ public async Task RegisterAsync(string email, string password)
+ {
+ string[] defaultDetail = ["An unknown error prevented registration from succeeding."];
+
+ try
+ {
+ // make the request
+ var result = await _httpClient.PostAsJsonAsync("register", new
+ {
+ email,
+ password
+ });
+
+ // successful?
+ if (result.IsSuccessStatusCode)
+ {
+ return new FormResult { Succeeded = true };
+ }
+
+ // body should contain details about why it failed
+ var details = await result.Content.ReadAsStringAsync();
+ var problemDetails = JsonDocument.Parse(details);
+ var errors = new List();
+ var errorList = problemDetails.RootElement.GetProperty("errors");
+
+ foreach (var errorEntry in errorList.EnumerateObject())
+ {
+ if (errorEntry.Value.ValueKind == JsonValueKind.String)
+ {
+ errors.Add(errorEntry.Value.GetString()!);
+ }
+ else if (errorEntry.Value.ValueKind == JsonValueKind.Array)
+ {
+ errors.AddRange(
+ errorEntry.Value.EnumerateArray().Select(
+ e => e.GetString() ?? string.Empty)
+ .Where(e => !string.IsNullOrEmpty(e)));
+ }
+ }
+
+ // return the error list
+ return new FormResult
+ {
+ Succeeded = false,
+ ErrorList = problemDetails == null ? defaultDetail : [.. errors]
+ };
+ }
+ catch { }
+
+ // unknown error
+ return new FormResult
+ {
+ Succeeded = false,
+ ErrorList = defaultDetail
+ };
+ }
+
+ ///
+ /// User login.
+ ///
+ /// The user's email address.
+ /// The user's password.
+ /// The result of the login request serialized to a .
+ public async Task LoginAsync(string email, string password)
+ {
+ try
+ {
+ // login with cookies
+ var result = await _httpClient.PostAsJsonAsync("login?useCookies=true", new
+ {
+ email,
+ password
+ });
+
+ // success?
+ if (result.IsSuccessStatusCode)
+ {
+ // need to refresh auth state
+ NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
+
+ // success!
+ return new FormResult { Succeeded = true };
+ }
+ }
+ catch { }
+
+ // unknown error
+ return new FormResult
+ {
+ Succeeded = false,
+ ErrorList = ["Invalid email and/or password."]
+ };
+ }
+
+ ///
+ /// Get authentication state.
+ ///
+ ///
+ /// Called by Blazor anytime and authentication-based decision needs to be made, then cached
+ /// until the changed state notification is raised.
+ ///
+ /// The authentication state asynchronous request.
+ public override async Task GetAuthenticationStateAsync()
+ {
+ _authenticated = false;
+
+ // default to not authenticated
+ var user = Unauthenticated;
+
+ try
+ {
+ // the user info endpoint is secured, so if the user isn't logged in this will fail
+ var userResponse = await _httpClient.GetAsync("manage/info");
+
+ // throw if user info wasn't retrieved
+ userResponse.EnsureSuccessStatusCode();
+
+ // user is authenticated,so let's build their authenticated identity
+ var userJson = await userResponse.Content.ReadAsStringAsync();
+ var userInfo = JsonSerializer.Deserialize(userJson, jsonSerializerOptions);
+
+ if (userInfo != null)
+ {
+ // in our system name and email are the same
+ var claims = new List
+ {
+ new(ClaimTypes.Name, userInfo.Email),
+ new(ClaimTypes.Email, userInfo.Email)
+ };
+
+ // add any additional claims
+ claims.AddRange(
+ userInfo.Claims
+ .Where(c => c.Key != ClaimTypes.Name && c.Key != ClaimTypes.Email)
+ .Select(c => new Claim(c.Key, c.Value)));
+
+ // tap the roles endpoint for the user's roles
+ var rolesResponse = await _httpClient.GetAsync("roles");
+
+ // throw if request fails
+ rolesResponse.EnsureSuccessStatusCode();
+
+ // read the response into a string
+ var rolesJson = await rolesResponse.Content.ReadAsStringAsync();
+
+ // deserialize the roles string into an array
+ var roles = JsonSerializer.Deserialize(rolesJson, jsonSerializerOptions);
+
+ // if there are roles, add them to the claims collection
+ if (roles?.Length > 0)
+ {
+ foreach (var role in roles)
+ {
+ if (!string.IsNullOrEmpty(role.Type) && !string.IsNullOrEmpty(role.Value))
+ {
+ claims.Add(new Claim(role.Type, role.Value, role.ValueType, role.Issuer, role.OriginalIssuer));
+ }
+ }
+ }
+
+ // set the principal
+ var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider));
+ user = new ClaimsPrincipal(id);
+ _authenticated = true;
+ }
+ }
+ catch { }
+
+ // return the state
+ return new AuthenticationState(user);
+ }
+
+ public async Task LogoutAsync()
+ {
+ const string Empty = "{}";
+ var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json");
+ await _httpClient.PostAsync("logout", emptyContent);
+ NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
+ }
+
+ public async Task CheckAuthenticatedAsync()
+ {
+ await GetAuthenticationStateAsync();
+ return _authenticated;
+ }
+
+ public class RoleClaim
+ {
+ public string? Issuer { get; set; }
+ public string? OriginalIssuer { get; set; }
+ public string? Type { get; set; }
+ public string? Value { get; set; }
+ public string? ValueType { get; set; }
+ }
+}
diff --git a/Shogi.UI/Identity/CookieMessageHandler.cs b/Shogi.UI/Identity/CookieMessageHandler.cs
new file mode 100644
index 0000000..3d78081
--- /dev/null
+++ b/Shogi.UI/Identity/CookieMessageHandler.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Components.WebAssembly.Http;
+
+namespace Shogi.UI.Identity;
+
+public class CookieCredentialsMessageHandler : DelegatingHandler
+{
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
+ request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]);
+
+ return base.SendAsync(request, cancellationToken);
+ }
+}
diff --git a/Shogi.UI/Identity/FormResult.cs b/Shogi.UI/Identity/FormResult.cs
new file mode 100644
index 0000000..c2bf8ac
--- /dev/null
+++ b/Shogi.UI/Identity/FormResult.cs
@@ -0,0 +1,14 @@
+namespace Shogi.UI.Identity;
+
+public class FormResult
+{
+ ///
+ /// Gets or sets a value indicating whether the action was successful.
+ ///
+ public bool Succeeded { get; set; }
+
+ ///
+ /// On failure, the problem details are parsed and returned in this array.
+ ///
+ public string[] ErrorList { get; set; } = [];
+}
\ No newline at end of file
diff --git a/Shogi.UI/Identity/IAccountManagement.cs b/Shogi.UI/Identity/IAccountManagement.cs
new file mode 100644
index 0000000..6417a8e
--- /dev/null
+++ b/Shogi.UI/Identity/IAccountManagement.cs
@@ -0,0 +1,31 @@
+namespace Shogi.UI.Identity;
+
+///
+/// Account management services.
+///
+public interface IAccountManagement
+{
+ ///
+ /// Login service.
+ ///
+ /// User's email.
+ /// User's password.
+ /// The result of the request serialized to .
+ public Task LoginAsync(string email, string password);
+
+ ///
+ /// Log out the logged in user.
+ ///
+ /// The asynchronous task.
+ public Task LogoutAsync();
+
+ ///
+ /// Registration service.
+ ///
+ /// User's email.
+ /// User's password.
+ /// The result of the request serialized to .
+ public Task RegisterAsync(string email, string password);
+
+ public Task CheckAuthenticatedAsync();
+}
diff --git a/Shogi.UI/Identity/UserInfo.cs b/Shogi.UI/Identity/UserInfo.cs
new file mode 100644
index 0000000..01e7809
--- /dev/null
+++ b/Shogi.UI/Identity/UserInfo.cs
@@ -0,0 +1,22 @@
+namespace Shogi.UI.Identity;
+
+///