Replace custom socket implementation with SignalR.

Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework
Also some UI redesign to accommodate different login experience.
This commit is contained in:
2024-08-25 03:46:44 +00:00
parent d688afaeae
commit 51d234d871
172 changed files with 3857 additions and 4045 deletions

6
Shogi.Api/ApiKeys.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace Shogi.Api;
public class ApiKeys
{
public string BrevoEmailService { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.SignalR;
namespace Shogi.Api.Application;
/// <summary>
/// Used to receive signals from connected clients.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.SignalR;
namespace Shogi.Api.Application;
/// <summary>
/// Used to send signals to connected clients.
/// </summary>
public class GameHubContext(IHubContext<GameHub> 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");
}
}

View File

@@ -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<ShogiUser> userManager,
GameHubContext gameHubContext)
{
public async Task<IActionResult> 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<IEnumerable<SessionDto>> ReadAllSessionMetadatas(string playerId)
{
return await queryRepository.ReadSessionsMetadata(playerId);
}
public async Task<Session?> 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<IActionResult> 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<IActionResult> 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!;
}
}

View File

@@ -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<ShogiUser> signInManager,
UserManager<ShogiUser> UserManager,
IConfiguration configuration) : ControllerBase
{
[Authorize("Admin")]
[HttpPost("TestAccount")]
public async Task<IActionResult> 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<IActionResult> 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();
}
}

View File

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

View File

@@ -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<ShogiUser> signInManager,
UserManager<ShogiUser> userManager) : ControllerBase
{
private readonly ISocketConnectionManager communicationManager;
private readonly IModelMapper mapper;
private readonly ISessionRepository sessionRepository;
private readonly IQueryRespository queryRespository;
private readonly ILogger<SessionsController> logger;
public SessionsController(
ISocketConnectionManager communicationManager,
IModelMapper mapper,
ISessionRepository sessionRepository,
IQueryRespository queryRespository,
ILogger<SessionsController> logger)
{
this.communicationManager = communicationManager;
this.mapper = mapper;
this.sessionRepository = sessionRepository;
this.queryRespository = queryRespository;
this.logger = logger;
}
[HttpPost]
public async Task<IActionResult> CreateSession()
{
var id = this.User.GetId();
if (string.IsNullOrEmpty(id))
{
return this.Unauthorized();
}
return await application.CreateSession(id);
}
[HttpPost]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
/// <summary>
/// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
[HttpGet("{sessionId}")]
public async Task<ActionResult<Session>> 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<ActionResult<ReadSessionsPlayerCountResponse>> GetSessionsPlayerCount()
{
return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId()));
}
[HttpGet()]
public async Task<ActionResult<SessionMetadata[]>> ReadAllSessionsMetadata()
{
var id = this.User.GetId();
if (id == null) return this.Unauthorized();
/// <summary>
/// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
[HttpGet("{name}")]
public async Task<ActionResult<ReadSessionResponse>> 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<IActionResult> 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<IActionResult> JoinSession(string name)
{
var session = await sessionRepository.ReadSession(name);
if (session == null) return this.NotFound();
[HttpPatch("{sessionId}/Move")]
public async Task<IActionResult> 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<IActionResult> 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);
}
}

View File

@@ -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<UserController> 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<CreateTokenResponse> GetWebSocketToken()
{
var userId = User.GetShogiUserId();
var displayName = User.GetShogiUserDisplayname();
var token = tokenCache.GenerateToken(userId);
return new CreateTokenResponse
{
DisplayName = displayName,
OneTimeToken = token,
UserId = userId
};
}
/// <summary>
/// </summary>
/// <param name="returnUrl">Used by cookie authentication.</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("LoginAsGuest")]
public async Task<IActionResult> 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<IActionResult> GuestLogout()
{
var signOutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var userId = User?.GetShogiUserId();
if (!string.IsNullOrEmpty(userId))
{
connectionManager.Unsubscribe(userId);
}
await signOutTask;
return Ok();
}
}

View File

@@ -1,39 +0,0 @@
namespace Shogi.Api
{
namespace anonymous_session.Middlewares
{
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
/// <summary>
/// TODO: Use this example in the guest session logic instead of custom claims.
/// </summary>
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);
}
}
}
}

View File

@@ -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
/// <summary>
/// Get Id from claims after applying shogi-specific claims transformations.
/// </summary>
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;
}
/// <summary>
/// Get display name from claims after applying shogi-specific claims transformations.
/// </summary>
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;
}
}

View File

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

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Shogi.Api.Identity;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ShogiUser>(options)
{
}

View File

@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Identity;
namespace Shogi.Api.Identity;
public class ShogiUser : IdentityUser
{
}

View File

@@ -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<string, Piece?> Map(IDictionary<string, Domain.ValueObjects.Piece?> 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<string, Piece?> Map(IDictionary<string, Domain.ValueObjects.Piece?> boardState);
}
}

View File

@@ -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);
}
/// <summary>
/// Retains all active socket connections and provides convenient methods for sending messages to clients.
/// </summary>
public class SocketConnectionManager : ISocketConnectionManager
{
/// <summary>Dictionary key is player name.</summary>
private readonly ConcurrentDictionary<string, WebSocket> connections;
private readonly JsonSerializerOptions serializeOptions;
/// <summary>Dictionary key is game name.</summary>
private readonly ILogger<SocketConnectionManager> logger;
public SocketConnectionManager(ILogger<SocketConnectionManager> logger)
{
this.logger = logger;
this.connections = new ConcurrentDictionary<string, WebSocket>();
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<Task>(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<Task>(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);
}

View File

@@ -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
{
/// <summary>
/// Key is userName or webSessionId
/// </summary>
private readonly ConcurrentDictionary<string, Guid> Tokens;
public SocketTokenCache()
{
Tokens = new ConcurrentDictionary<string, Guid>();
}
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;
}
/// <returns>User name associated to the guid or null.</returns>
public string? GetUsername(Guid guid)
{
var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key;
if (userName != null)
{
Tokens.Remove(userName, out _);
}
return userName;
}
}
}

View File

@@ -0,0 +1,279 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Shogi.Api.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,224 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Shogi.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(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<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(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<string>(type: "nvarchar(450)", nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<string>(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<string>(type: "nvarchar(450)", nullable: false),
RoleId = table.Column<string>(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<string>(type: "nvarchar(450)", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Value = table.Column<string>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -0,0 +1,276 @@
// <auto-generated />
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<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Shogi.Api.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,39 +0,0 @@
using System.Collections.ObjectModel;
namespace Shogi.Api.Models;
public class User
{
public static readonly ReadOnlyCollection<string> Adjectives = new(new[] {
"Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly", "Queenly", "Blushing", "Brave",
"Brainy", "Eager", "Itchy", "Fierce"
});
public static readonly ReadOnlyCollection<string> 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;
}
}

View File

@@ -1,9 +0,0 @@
namespace Shogi.Api.Models
{
public enum WhichLoginPlatform
{
Unknown,
Microsoft,
Guest
}
}

View File

@@ -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<string[]>() ?? 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<SessionRepository>();
builder.Services.AddTransient<QueryRepository>();
builder.Services.AddTransient<ShogiApplication>();
builder.Services.AddTransient<GameHubContext>();
builder.Services.AddHttpClient<IEmailSender, EmailSender>();
builder.Services.Configure<ApiKeys>(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<string[]>() ?? 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<ShogiUser>();
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<ISocketService>();
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<JsonOptions>(options =>
{
options.SerializerOptions.WriteIndented = true;
});
}
private static void ConfigureDependencyInjection(WebApplicationBuilder builder)
{
var services = builder.Services;
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<ISocketService, SocketService>();
services.AddTransient<IClaimsTransformation, ShogiUserClaimsTransformer>();
services.AddTransient<IShogiUserClaimsTransformer, ShogiUserClaimsTransformer>();
services.AddTransient<IUserRepository, UserRepository>();
services.AddTransient<ISessionRepository, SessionRepository>();
services.AddTransient<IQueryRespository, QueryRepository>();
services.AddTransient<IModelMapper, ModelMapper>();
}
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<string, string>
{
{ "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<string>()
}
});
});
}
}
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>("/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<ApplicationDbContext>(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<ShogiUser>(options =>
{
options.SignIn.RequireConfirmedEmail = true;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
// I shouldn't this because I have it above, right?
//builder.Services.Configure<IdentityOptions>(options =>
//{
// options.SignIn.RequireConfirmedEmail = true;
// options.User.RequireUniqueEmail = true;
//});
builder.Services.ConfigureApplicationCookie(options =>
{
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(3);
});
}

View File

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

View File

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

View File

@@ -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<IEnumerable<SessionDto>> ReadSessionsMetadata(string playerId)
{
using var connection = new SqlConnection(this.connectionString);
public async Task<ReadSessionsPlayerCountResponse> 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<SessionMetadata>();
var otherSessions = await results.ReadAsync<SessionMetadata>();
return new ReadSessionsPlayerCountResponse
{
PlayerHasJoinedSessions = joinedSessions.ToList(),
AllOtherSessions = otherSessions.ToList()
};
}
/// <summary>
/// </summary>
/// <returns>A <see cref="ValueTuple"/> with Item1 as player 1 and Item2 as player 2.</returns>
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<SessionDto>();
}
}
public interface IQueryRespository
{
Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName);
Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName);
}

View File

@@ -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<MoveDto> 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<Session?> 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<SessionDto>();
if (!sessionDtos.Any())
{
return (null, []);
}
var sessionDtos = await results.ReadAsync<SessionDto>();
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<MoveDto>();
var moveDtos = await results.ReadAsync<MoveDto>();
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<Session?> 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);
}
}

View File

@@ -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<User?> ReadUser(string id)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryAsync<User>(
"user.ReadUser",
new { Name = id },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public interface IUserRepository
{
Task CreateUser(User user);
Task<User?> ReadUser(string id);
}

View File

@@ -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);
}
/// <summary>
/// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages.
/// </summary>
public class SocketService : ISocketService
{
private readonly ILogger<SocketService> logger;
private readonly ISocketConnectionManager communicationManager;
private readonly ISocketTokenCache tokenManager;
public SocketService(
ILogger<SocketService> 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<ISocketMessage>(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}");
}
}
}
}
}
}

View File

@@ -23,13 +23,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" />
<PackageReference Include="Azure.Identity" Version="1.11.2" />
<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="FluentValidation" Version="11.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
<PackageReference Include="Microsoft.Identity.Web" Version="2.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
</ItemGroup>

View File

@@ -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;
/// <summary>
/// Standardizes the claims from third party issuers. Also registers new msal users in the database.
/// </summary>
public class ShogiUserClaimsTransformer : IShogiUserClaimsTransformer
{
private readonly IUserRepository userRepository;
public ShogiUserClaimsTransformer(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var newPrincipal = IsMicrosoft(principal)
? await CreateClaimsFromMicrosoftPrincipal(principal)
: await CreateClaimsFromGuestPrincipal(principal);
return newPrincipal;
}
public async Task<ClaimsPrincipal> 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<ClaimsPrincipal> 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<Claim>(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<ClaimsPrincipal> CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal);
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Shogi.Contracts.Api;
public class CreateSessionCommand
{
[Required]
public string Name { get; set; }
}

View File

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

View File

@@ -12,7 +12,7 @@ public class MovePieceCommand : IValidatableObject
/// </summary>
public MovePieceCommand()
{
To = string.Empty;
this.To = string.Empty;
}
/// <summary>
@@ -20,9 +20,9 @@ public class MovePieceCommand : IValidatableObject
/// </summary>
public MovePieceCommand(string from, string to, bool isPromotion)
{
From = from;
To = to;
IsPromotion = isPromotion;
this.From = from;
this.To = to;
this.IsPromotion = isPromotion;
}
/// <summary>
@@ -30,8 +30,8 @@ public class MovePieceCommand : IValidatableObject
/// </summary>
public MovePieceCommand(WhichPiece pieceFromHand, string to)
{
PieceFromHand = pieceFromHand;
To = to;
this.PieceFromHand = pieceFromHand;
this.To = to;
}
/// <summary>
@@ -57,21 +57,21 @@ public class MovePieceCommand : IValidatableObject
public IEnumerable<ValidationResult> 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");
}
}
}

View File

@@ -1,10 +0,0 @@
using Shogi.Contracts.Types;
using System.Collections.Generic;
namespace Shogi.Contracts.Api;
public class ReadSessionsPlayerCountResponse
{
public IList<SessionMetadata> PlayerHasJoinedSessions { get; set; }
public IList<SessionMetadata> AllOtherSessions { get; set; }
}

View File

@@ -1,8 +0,0 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Api;
public class ReadSessionResponse
{
public Session Session { get; set; }
}

View File

@@ -10,4 +10,8 @@
<Description>Contains DTOs use for http requests to Shogi backend services.</Description>
</PropertyGroup>
<ItemGroup>
<Folder Include="Api\Queries\" />
</ItemGroup>
</Project>

View File

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

View File

@@ -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; }
/// <summary>
/// The player that made the move.
/// </summary>
public string PlayerName { get; set; }
public PlayerHasMovedMessage()
{
Action = SocketAction.PieceMoved;
SessionName = string.Empty;
PlayerName = string.Empty;
}
}

View File

@@ -1,8 +0,0 @@
using Shogi.Contracts.Types;
namespace Shogi.Contracts.Socket;
public class SessionCreatedSocketMessage : ISocketMessage
{
public SocketAction Action => SocketAction.SessionCreated;
}

View File

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

View File

@@ -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; }
/// <summary>
/// Email
/// </summary>
public string Player1 { get; set; }
/// <summary>
/// Email. Null if no second player exists.
/// </summary>
public string? Player2 { get; set; }
public Guid SessionId { get; set; }
public BoardState BoardState { get; set; }
}

View File

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

View File

@@ -1,9 +0,0 @@
namespace Shogi.Contracts.Types
{
public enum SocketAction
{
SessionCreated,
SessionJoined,
PieceMoved
}
}

View File

@@ -1,17 +0,0 @@
namespace Shogi.Contracts.Types;
public class User
{
public string Id { get; set; } = string.Empty;
/// <summary>
/// A display name for the user.
/// </summary>
public string Name { get; set; } = string.Empty;
public User(string id, string name)
{
Id = id;
Name = name;
}
}

View File

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

View File

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

View File

@@ -10,6 +10,5 @@ Post-Deployment Script Template
--------------------------------------------------------------------------------------
*/
:r .\Scripts\PopulateLoginPlatforms.sql
:r .\Scripts\PopulatePieces.sql
:r .\Scripts\EnableSnapshotIsolationLevel.sql

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
CREATE PROCEDURE [session].[DeleteSession]
@Name [session].[SessionName]
@Id [session].[SessionSurrogateKey]
AS
DELETE FROM [session].[Session] WHERE [Name] = @Name;
DELETE FROM [session].[Session] WHERE [Id] = @Id;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]),

View File

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

View File

@@ -1,2 +0,0 @@
CREATE TYPE [session].[SessionName]
FROM nvarchar(50) NOT NULL

View File

@@ -0,0 +1,2 @@
CREATE TYPE [session].[SessionSurrogateKey]
FROM CHAR(36) NOT NULL

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<Operations Version="1.0" xmlns="http://schemas.microsoft.com/sqlserver/dac/Serialization/2012/02">
<Operation Name="Rename Refactor" Key="3cbf39d6-79cd-48fe-b8e3-dab0060d1092" ChangeDateTime="08/17/2024 03:58:12">
<Property Name="ElementName" Value="[session].[Session].[Name]" />
<Property Name="ElementType" Value="SqlSimpleColumn" />
<Property Name="ParentElementName" Value="[session].[Session]" />
<Property Name="ParentElementType" Value="SqlTable" />
<Property Name="NewName" Value="SessionId" />
</Operation>
<Operation Name="Rename Refactor" Key="6ec1662d-c600-4558-af11-95a94acd23d9" ChangeDateTime="08/17/2024 15:59:09">
<Property Name="ElementName" Value="[session].[Session].[Created]" />
<Property Name="ElementType" Value="SqlSimpleColumn" />
<Property Name="ParentElementName" Value="[session].[Session]" />
<Property Name="ParentElementType" Value="SqlTable" />
<Property Name="NewName" Value="CreatedDate" />
</Operation>
</Operations>

View File

@@ -58,36 +58,27 @@
<ItemGroup>
<Folder Include="Properties" />
<Folder Include="Session" />
<Folder Include="User" />
<Folder Include="Session\Tables" />
<Folder Include="Session\Stored Procedures" />
<Folder Include="User\Tables" />
<Folder Include="Session\Types" />
<Folder Include="User\Types" />
<Folder Include="User\StoredProcedures" />
<Folder Include="Post Deployment" />
<Folder Include="Post Deployment\Scripts" />
<Folder Include="Session\Functions" />
</ItemGroup>
<ItemGroup>
<Build Include="Session\session.sql" />
<Build Include="User\user.sql" />
<Build Include="Session\Tables\Session.sql" />
<Build Include="Session\Stored Procedures\CreateSession.sql" />
<Build Include="User\Tables\User.sql" />
<Build Include="Session\Types\SessionName.sql" />
<Build Include="User\Types\UserName.sql" />
<Build Include="User\StoredProcedures\CreateUser.sql" />
<Build Include="Session\Stored Procedures\ReadSessionPlayerCount.sql" />
<Build Include="User\StoredProcedures\ReadUser.sql" />
<Build Include="User\Tables\LoginPlatform.sql" />
<None Include="Post Deployment\Scripts\PopulateLoginPlatforms.sql" />
<Build Include="Session\Types\SessionSurrogateKey.sql" />
<Build Include="Session\Stored Procedures\SetPlayer2.sql" />
<Build Include="Session\Stored Procedures\ReadSession.sql" />
<Build Include="Session\Tables\Move.sql" />
<Build Include="Session\Tables\Piece.sql" />
<Build Include="Session\Stored Procedures\DeleteSession.sql" />
<Build Include="Session\Stored Procedures\CreateMove.sql" />
<Build Include="Session\Stored Procedures\ReadUsersBySession.sql" />
<Build Include="Session\Stored Procedures\ReadSessionsMetadata.sql" />
<Build Include="AspNetUsersId.sql" />
<Build Include="Session\Functions\MaxNewSessionsPerUser.sql" />
</ItemGroup>
<ItemGroup>
<PostDeploy Include="Post Deployment\Script.PostDeployment.sql" />
@@ -97,4 +88,7 @@
<None Include="Post Deployment\Scripts\EnableSnapshotIsolationLevel.sql" />
<None Include="FirstTimeSetup.sql" />
</ItemGroup>
<ItemGroup>
<RefactorLog Include="Shogi.Database.refactorlog" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
CREATE TABLE [user].[LoginPlatform]
(
[Platform] NVARCHAR(20) NOT NULL PRIMARY KEY
)

View File

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

View File

@@ -1,2 +0,0 @@
CREATE TYPE [user].[UserName]
FROM nvarchar(100) NOT NULL

View File

@@ -1 +0,0 @@
CREATE SCHEMA [user]

View File

@@ -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; }
/// <summary>
/// The email of the player which created the session.
/// </summary>
public string Player1 { get; } = player1Name;
/// <summary>
/// The User.Id of the player which created the session.
/// </summary>
public string Player1 { get; }
/// <summary>
/// The email of the second player.
/// </summary>
public string? Player2 { get; private set; }
/// <summary>
/// The User.Id of the second player.
/// </summary>
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;
}
}

View File

@@ -2,7 +2,7 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>

View File

@@ -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;
/// <summary>
/// Handles state for cookie-based auth.
/// </summary>
public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement
{
/// <summary>
/// Map the JavaScript-formatted properties to C#-formatted classes.
/// </summary>
private readonly JsonSerializerOptions jsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// Special auth client.
/// </summary>
private readonly HttpClient _httpClient;
/// <summary>
/// Authentication state.
/// </summary>
private bool _authenticated = false;
/// <summary>
/// Default principal for anonymous (not authenticated) users.
/// </summary>
private readonly ClaimsPrincipal Unauthenticated =
new(new ClaimsIdentity());
/// <summary>
/// Create a new instance of the auth provider.
/// </summary>
/// <param name="httpClientFactory">Factory to retrieve auth client.</param>
public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory)
=> _httpClient = httpClientFactory.CreateClient("Auth");
/// <summary>
/// Register a new user.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result serialized to a <see cref="FormResult"/>.
/// </returns>
public async Task<FormResult> 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<string>();
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
};
}
/// <summary>
/// User login.
/// </summary>
/// <param name="email">The user's email address.</param>
/// <param name="password">The user's password.</param>
/// <returns>The result of the login request serialized to a <see cref="FormResult"/>.</returns>
public async Task<FormResult> 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."]
};
}
/// <summary>
/// Get authentication state.
/// </summary>
/// <remarks>
/// Called by Blazor anytime and authentication-based decision needs to be made, then cached
/// until the changed state notification is raised.
/// </remarks>
/// <returns>The authentication state asynchronous request.</returns>
public override async Task<AuthenticationState> 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<UserInfo>(userJson, jsonSerializerOptions);
if (userInfo != null)
{
// in our system name and email are the same
var claims = new List<Claim>
{
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<RoleClaim[]>(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<bool> 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; }
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace Shogi.UI.Identity;
public class CookieCredentialsMessageHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,14 @@
namespace Shogi.UI.Identity;
public class FormResult
{
/// <summary>
/// Gets or sets a value indicating whether the action was successful.
/// </summary>
public bool Succeeded { get; set; }
/// <summary>
/// On failure, the problem details are parsed and returned in this array.
/// </summary>
public string[] ErrorList { get; set; } = [];
}

View File

@@ -0,0 +1,31 @@
namespace Shogi.UI.Identity;
/// <summary>
/// Account management services.
/// </summary>
public interface IAccountManagement
{
/// <summary>
/// Login service.
/// </summary>
/// <param name="email">User's email.</param>
/// <param name="password">User's password.</param>
/// <returns>The result of the request serialized to <see cref="FormResult"/>.</returns>
public Task<FormResult> LoginAsync(string email, string password);
/// <summary>
/// Log out the logged in user.
/// </summary>
/// <returns>The asynchronous task.</returns>
public Task LogoutAsync();
/// <summary>
/// Registration service.
/// </summary>
/// <param name="email">User's email.</param>
/// <param name="password">User's password.</param>
/// <returns>The result of the request serialized to <see cref="FormResult"/>.</returns>
public Task<FormResult> RegisterAsync(string email, string password);
public Task<bool> CheckAuthenticatedAsync();
}

View File

@@ -0,0 +1,22 @@
namespace Shogi.UI.Identity;
/// <summary>
/// User info from identity endpoint to establish claims.
/// </summary>
public class UserInfo
{
/// <summary>
/// The email address.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// A value indicating whether the email has been confirmed yet.
/// </summary>
public bool IsEmailConfirmed { get; set; }
/// <summary>
/// The list of claims for the user.
/// </summary>
public Dictionary<string, string> Claims { get; set; } = [];
}

View File

@@ -0,0 +1,7 @@
@inherits LayoutComponentBase
<div class="MainLayout PrimaryTheme">
<NavMenu />
@Body
</div>

View File

@@ -0,0 +1,5 @@
.MainLayout {
display: grid;
grid-template-columns: auto 1fr;
place-items: stretch;
}

View File

@@ -0,0 +1,52 @@
@inject NavigationManager navigator
@inject ShogiApi Api
<div class="NavMenu PrimaryTheme ThemeVariant--Contrast">
<h1>Shogi</h1>
<p>
<a href="/">Home</a>
</p>
<AuthorizeView>
<p>
<a href="/search">Search</a>
</p>
<p>
<button class="href" @onclick="CreateSession">Create</button>
</p>
</AuthorizeView>
<div class="spacer" />
<AuthorizeView>
<Authorized>
<p>@context.User.Identity?.Name</p>
<p>
<a href="logout">Logout</a>
</p>
</Authorized>
<NotAuthorized>
<p>
<a href="login">Login</a>
</p>
<p>
<a href="register">Register</a>
</p>
</NotAuthorized>
</AuthorizeView>
</div>
@code {
async Task CreateSession()
{
var sessionId = await Api.PostSession();
if (!string.IsNullOrEmpty(sessionId))
{
navigator.NavigateTo($"/play/{sessionId}");
}
}
}

View File

@@ -0,0 +1,15 @@
.NavMenu {
display: flex;
flex-direction: column;
border-right: 2px solid #444;
}
.NavMenu > * {
padding: 0 0.5rem;
}
.NavMenu h1 {
}
.NavMenu .spacer {
flex: 1;
}

View File

@@ -1,27 +0,0 @@
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager navigationManager
<RemoteAuthenticatorView Action="@Action" LogInFailed="LoginFailed" LogOutSucceeded="LogoutSuccess()">
</RemoteAuthenticatorView>
@code {
[Parameter] public string? Action { get; set; }
// https://github.com/dotnet/aspnetcore/blob/main/src/Components/WebAssembly/WebAssembly.Authentication/src/Models/RemoteAuthenticationActions.cs
// https://github.com/dotnet/aspnetcore/blob/7c810658463f35c39c54d5fb8a8dbbfd463bf747/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs
RenderFragment LoginFailed(string message)
{
Console.WriteLine($"Failed to login because: {message}");
if (message.Contains("AADSTS65004", StringComparison.OrdinalIgnoreCase))
{
return builder => navigationManager.NavigateTo("/");
}
return builder => navigationManager.NavigateTo("/error");
}
RenderFragment LogoutSuccess()
{
return builder => navigationManager.NavigateTo("/");
}
}

View File

@@ -1,6 +1,6 @@
@page "/error"
<main>
<main class="PrimaryTheme">
<div class="card">
<div class="card-body">
<h1 class="card-title">Oops!</h1>

View File

@@ -1,136 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Shogi.UI.Pages.Home.Api;
using Shogi.UI.Shared;
namespace Shogi.UI.Pages.Home.Account;
public class AccountManager
{
private readonly AccountState accountState;
private readonly IShogiApi shogiApi;
private readonly ILocalStorage localStorage;
private readonly AuthenticationStateProvider authState;
private readonly NavigationManager navigation;
private readonly ShogiSocket shogiSocket;
public AccountManager(
AccountState accountState,
IShogiApi unauthenticatedClient,
AuthenticationStateProvider authState,
ILocalStorage localStorage,
NavigationManager navigation,
ShogiSocket shogiSocket)
{
this.accountState = accountState;
this.shogiApi = unauthenticatedClient;
this.authState = authState;
this.localStorage = localStorage;
this.navigation = navigation;
this.shogiSocket = shogiSocket;
}
private Task SetUser(User user) => accountState.SetUser(user);
public async Task LoginWithGuestAccount()
{
var response = await shogiApi.GetToken(WhichAccountPlatform.Guest);
if (response != null)
{
await SetUser(new User
{
DisplayName = response.DisplayName,
Id = response.UserId,
WhichAccountPlatform = WhichAccountPlatform.Guest
});
await localStorage.SetAccountPlatform(WhichAccountPlatform.Guest);
// TODO: OpenAsync() sometimes doesn't return, probably because of the fire'n'forget task inside it. Figure that out.
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
}
else
{
throw new InvalidOperationException("Failed to get token from server during guest login.");
}
}
public async Task LoginWithMicrosoftAccount()
{
var state = await authState.GetAuthenticationStateAsync();
if (state?.User?.Identity?.Name == null || state.User?.Identity?.IsAuthenticated == false)
{
// Set the login platform so that we know to log in with microsoft after being redirected away from the UI.
await localStorage.SetAccountPlatform(WhichAccountPlatform.Microsoft);
navigation.NavigateToLogin("authentication/login");
return;
}
}
/// <summary>
/// Try to log in with the account used from the previous browser session.
/// </summary>
/// <returns>True if login succeeded.</returns>
public async Task<bool> TryLoginSilentAsync()
{
var platform = await localStorage.GetAccountPlatform();
if (platform == WhichAccountPlatform.Guest)
{
var response = await shogiApi.GetToken(WhichAccountPlatform.Guest);
if (response != null)
{
await accountState.SetUser(new User(
Id: response.UserId,
DisplayName: response.DisplayName,
WhichAccountPlatform: WhichAccountPlatform.Guest));
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
return true;
}
}
else if (platform == WhichAccountPlatform.Microsoft)
{
var state = await authState.GetAuthenticationStateAsync();
if (state.User?.Identity?.Name != null)
{
var response = await shogiApi.GetToken(WhichAccountPlatform.Microsoft);
if (response == null)
{
// Login failed, so reset local storage to avoid putting the user in a broken state.
await localStorage.DeleteAccountPlatform();
return false;
}
var id = state.User.Claims.Single(claim => claim.Type == "oid").Value;
var displayName = state.User.Identity.Name;
await accountState.SetUser(new User(
Id: id,
DisplayName: displayName,
WhichAccountPlatform: WhichAccountPlatform.Microsoft));
await shogiSocket.OpenAsync(response.OneTimeToken.ToString());
return true;
}
}
return false;
}
public async Task LogoutAsync()
{
var platform = await localStorage.GetAccountPlatform();
await localStorage.DeleteAccountPlatform();
await accountState.SetUser(null);
if (platform == WhichAccountPlatform.Guest)
{
await shogiApi.GuestLogout();
}
else if (platform == WhichAccountPlatform.Microsoft)
{
navigation.NavigateToLogout("authentication/logout");
}
else
{
throw new InvalidOperationException("Tried to logout without a valid account platform.");
}
}
}

View File

@@ -1,27 +0,0 @@
using static Shogi.UI.Shared.Events;
namespace Shogi.UI.Pages.Home.Account;
public class AccountState
{
public event AsyncEventHandler<LoginEventArgs>? LoginChangedEvent;
public User? User { get; private set; }
public Task SetUser(User? user)
{
User = user;
return EmitLoginChangedEvent();
}
private async Task EmitLoginChangedEvent()
{
if (LoginChangedEvent is not null)
{
await LoginChangedEvent.Invoke(new LoginEventArgs
{
User = User
});
}
}
}

View File

@@ -1,23 +0,0 @@
using Shogi.UI.Shared;
namespace Shogi.UI.Pages.Home.Account;
public static class LocalStorageExtensions
{
private const string AccountPlatform = "AccountPlatform";
public static Task<WhichAccountPlatform?> GetAccountPlatform(this ILocalStorage self)
{
return self.Get<WhichAccountPlatform>(AccountPlatform).AsTask();
}
public static Task SetAccountPlatform(this ILocalStorage self, WhichAccountPlatform platform)
{
return self.Set(AccountPlatform, platform.ToString()).AsTask();
}
public static Task DeleteAccountPlatform(this ILocalStorage self)
{
return self.Delete(AccountPlatform).AsTask();
}
}

View File

@@ -1,6 +0,0 @@
namespace Shogi.UI.Pages.Home.Account;
public class LoginEventArgs : EventArgs
{
public User? User { get; set; }
}

View File

@@ -1,9 +0,0 @@
namespace Shogi.UI.Pages.Home.Account
{
public readonly record struct User(
string Id,
string DisplayName,
WhichAccountPlatform WhichAccountPlatform)
{
}
}

View File

@@ -1,8 +0,0 @@
namespace Shogi.UI.Pages.Home.Account
{
public enum WhichAccountPlatform
{
Guest,
Microsoft
}
}

View File

@@ -1,26 +0,0 @@
using Microsoft.AspNetCore.Components.WebAssembly.Http;
namespace Shogi.UI.Pages.Home.Api
{
public class CookieCredentialsMessageHandler : DelegatingHandler
{
public CookieCredentialsMessageHandler()
{
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);
try
{
return base.SendAsync(request, cancellationToken);
}
catch
{
Console.WriteLine("Catch!");
return base.SendAsync(request, cancellationToken);
}
}
}
}

View File

@@ -1,17 +0,0 @@
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using Shogi.UI.Pages.Home.Account;
using System.Net;
namespace Shogi.UI.Pages.Home.Api;
public interface IShogiApi
{
Task<Session?> GetSession(string name);
Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount();
Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform);
Task GuestLogout();
Task Move(string sessionName, MovePieceCommand move);
Task<HttpResponseMessage> PatchJoinGame(string name);
Task<HttpStatusCode> PostSession(string name, bool isPrivate);
}

View File

@@ -1,23 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
namespace Shogi.UI.Pages.Home.Api
{
public class MsalMessageHandler : AuthorizationMessageHandler
{
public MsalMessageHandler(IAccessTokenProvider provider, NavigationManager navigation) : base(provider, navigation)
{
ConfigureHandler(
authorizedUrls: new[] { "https://api.lucaserver.space/Shogi.Api", "https://localhost:5001" },
scopes: new string[] {
"api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope",
//"offline_access",
});
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return base.SendAsync(request, cancellationToken);
}
}
}

View File

@@ -1,106 +0,0 @@
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using Shogi.UI.Pages.Home.Account;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Shogi.UI.Pages.Home.Api
{
public class ShogiApi : IShogiApi
{
public const string GuestClientName = "Guest";
public const string MsalClientName = "Msal";
private readonly JsonSerializerOptions serializerOptions;
private readonly AccountState accountState;
private readonly HttpClient guestHttpClient;
private readonly HttpClient msalHttpClient;
private readonly string baseUrl;
public ShogiApi(IHttpClientFactory clientFactory, AccountState accountState, IConfiguration configuration)
{
this.serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
this.accountState = accountState;
this.guestHttpClient = clientFactory.CreateClient(GuestClientName);
this.msalHttpClient = clientFactory.CreateClient(MsalClientName);
this.baseUrl = configuration["ShogiApiUrl"] ?? throw new InvalidOperationException("Configuration missing.");
this.baseUrl = this.baseUrl.TrimEnd('/');
}
private HttpClient HttpClient => accountState.User?.WhichAccountPlatform switch
{
WhichAccountPlatform.Guest => this.guestHttpClient,
WhichAccountPlatform.Microsoft => this.msalHttpClient,
_ => throw new InvalidOperationException("AccountState.User must not be null during API call.")
};
public async Task GuestLogout()
{
var response = await this.guestHttpClient.PutAsync(RelativeUri("User/GuestLogout"), null);
response.EnsureSuccessStatusCode();
}
public async Task<Session?> GetSession(string name)
{
var response = await HttpClient.GetAsync(RelativeUri($"Sessions/{name}"));
if (response.IsSuccessStatusCode)
{
return (await response.Content.ReadFromJsonAsync<ReadSessionResponse>(serializerOptions))?.Session;
}
return null;
}
public async Task<ReadSessionsPlayerCountResponse?> GetSessionsPlayerCount()
{
var response = await HttpClient.GetAsync(RelativeUri("Sessions/PlayerCount"));
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<ReadSessionsPlayerCountResponse>(serializerOptions);
}
return null;
}
/// <summary>
/// Logs the user into the API and returns a token which can be used to request a socket connection.
/// </summary>
public async Task<CreateTokenResponse?> GetToken(WhichAccountPlatform whichAccountPlatform)
{
var httpClient = whichAccountPlatform == WhichAccountPlatform.Microsoft
? this.msalHttpClient
: this.guestHttpClient;
var response = await httpClient.GetAsync(RelativeUri("User/Token"));
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(content))
{
return JsonSerializer.Deserialize<CreateTokenResponse>(content, serializerOptions);
}
}
return null;
}
public async Task Move(string sessionName, MovePieceCommand command)
{
await this.HttpClient.PatchAsync(RelativeUri($"Sessions/{sessionName}/Move"), JsonContent.Create(command));
}
public async Task<HttpStatusCode> PostSession(string name, bool isPrivate)
{
var response = await HttpClient.PostAsJsonAsync(RelativeUri("Sessions"), new CreateSessionCommand
{
Name = name,
});
return response.StatusCode;
}
public Task<HttpResponseMessage> PatchJoinGame(string name)
{
return HttpClient.PatchAsync(RelativeUri($"Sessions/{name}/Join"), null);
}
private Uri RelativeUri(string path) => new($"{this.baseUrl}/{path}", UriKind.Absolute);
}
}

View File

@@ -1,79 +0,0 @@
@using Shogi.Contracts.Api
@using Shogi.Contracts.Socket;
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@inject IShogiApi ShogiApi
@inject AccountState Account;
@inject PromotePrompt PromotePrompt;
@inject ShogiSocket ShogiSocket;
@if (session == null)
{
<EmptyGameBoard />
}
else if (isSpectating)
{
<SpectatorGameBoard Session="session" />
}
else
{
<SeatedGameBoard Perspective="perspective" Session="session" />
}
@code {
[Parameter]
public string? SessionName { get; set; }
Session? session;
private WhichPlayer perspective;
private bool isSpectating;
protected override void OnInitialized()
{
base.OnInitialized();
ShogiSocket.OnPlayerMoved += OnPlayerMoved_FetchSession;
ShogiSocket.OnSessionJoined += OnSessionJoined_FetchSession;
}
protected override async Task OnParametersSetAsync()
{
await FetchSession();
}
Task OnSessionJoined_FetchSession(SessionJoinedByPlayerSocketMessage args)
{
if (args.SessionName == SessionName)
{
return FetchSession();
}
return Task.CompletedTask;
}
async Task FetchSession()
{
if (!string.IsNullOrWhiteSpace(SessionName))
{
this.session = await ShogiApi.GetSession(SessionName);
if (this.session != null)
{
var accountId = Account.User?.Id;
this.perspective = accountId == session.Player1.Id ? WhichPlayer.Player1 : WhichPlayer.Player2;
Console.WriteLine(new { this.perspective, accountId });
this.isSpectating = !(accountId == this.session.Player1.Id || accountId == this.session.Player2?.Id);
}
StateHasChanged();
}
}
Task OnPlayerMoved_FetchSession(PlayerHasMovedMessage args)
{
if (args.SessionName == SessionName)
{
return FetchSession();
}
return Task.CompletedTask;
}
}

View File

@@ -1,196 +0,0 @@
@using Shogi.Contracts.Types;
@using System.Text.Json;
@inject PromotePrompt PromotePrompt;
@inject AccountState AccountState;
<article class="game-board">
@if (IsSpectating)
{
<aside class="icons">
<div class="spectating" title="You are spectating.">
<svg width="32" height="32" fill="currentColor">
<use xlink:href="css/bootstrap/bootstrap-icons.svg#camera-reels" />
</svg>
</div>
</aside>
}
<!-- Game board -->
<section class="board" data-perspective="@Perspective">
@for (var rank = 1; rank < 10; rank++)
{
foreach (var file in Files)
{
var position = $"{file}{rank}";
var piece = Session?.BoardState.Board[position];
var isSelected = piece != null && SelectedPosition == position;
<div class="tile" @onclick="OnClickTileInternal(piece, position)"
data-position="@(position)"
data-selected="@(isSelected)"
style="grid-area: @position">
@if (piece != null){
<GamePiece Piece="piece" Perspective="Perspective" />
}
</div>
}
}
<div class="ruler vertical" style="grid-area: rank">
<span>9</span>
<span>8</span>
<span>7</span>
<span>6</span>
<span>5</span>
<span>4</span>
<span>3</span>
<span>2</span>
<span>1</span>
</div>
<div class="ruler" style="grid-area: file">
<span>A</span>
<span>B</span>
<span>C</span>
<span>D</span>
<span>E</span>
<span>F</span>
<span>G</span>
<span>H</span>
<span>I</span>
</div>
<!-- Promote prompt -->
<div class="promote-prompt" data-visible="@PromotePrompt.IsVisible">
<p>Do you wish to promote?</p>
<div>
<button type="button">Yes</button>
<button type="button">No</button>
<button type="button">Cancel</button>
</div>
</div>
</section>
<!-- Side board -->
@if (Session != null)
{
<aside class="side-board">
<div class="player-area">
<div class="hand">
@if (opponentHand.Any())
{
@foreach (var piece in opponentHand)
{
<div class="tile">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
</div>
<p class="text-center">Opponent Hand</p>
</div>
<div class="spacer place-self-center text-center">
<p>@opponentName</p>
<p title="It is @(IsMyTurn ? "your" : "their") turn.">
<svg width="32" height="32" fill="currentColor">
@if (IsMyTurn)
{
<use xlink:href="css/bootstrap/bootstrap-icons.svg#chevron-down" />
}
else
{
<use xlink:href="css/bootstrap/bootstrap-icons.svg#chevron-up" />
}
</svg>
</p>
<p>@userName</p>
</div>
<div class="player-area">
@if (Session.Player2 == null && Session.Player1.Id != AccountState.User?.Id)
{
<div class="place-self-center">
<p>Seat is Empty</p>
<button @onclick="OnClickJoinGameInternal">Join Game</button>
</div>
}
else
{
<p class="text-center">Hand</p>
<div class="hand">
@if (userHand.Any())
{
@foreach (var piece in userHand)
{
<div @onclick="OnClickHandInternal(piece)"
class="tile"
data-selected="@(piece.WhichPiece == SelectedPieceFromHand)">
<GamePiece Piece="piece" Perspective="Perspective" />
</div>
}
}
</div>
}
</div>
</aside>
}
</article>
@code {
static readonly string[] Files = new[] { "A", "B", "C", "D", "E", "F", "G", "H", "I" };
/// <summary>
/// When true, an icon is displayed indicating that the user is spectating.
/// </summary>
[Parameter] public bool IsSpectating { get; set; } = false;
[Parameter] public WhichPlayer Perspective { get; set; }
[Parameter] public Session? Session { get; set; }
[Parameter] public string? SelectedPosition { get; set; }
[Parameter] public WhichPiece? SelectedPieceFromHand { get; set; }
// TODO: Exchange these OnClick actions for events like "SelectionChangedEvent" and "MoveFromBoardEvent" and "MoveFromHandEvent".
[Parameter] public Func<Piece?, string, Task>? OnClickTile { get; set; }
[Parameter] public Func<Piece, Task>? OnClickHand { get; set; }
[Parameter] public Func<Task>? OnClickJoinGame { get; set; }
[Parameter] public bool IsMyTurn { get; set; }
private IReadOnlyCollection<Piece> opponentHand;
private IReadOnlyCollection<Piece> userHand;
private string? userName;
private string? opponentName;
public GameBoardPresentation()
{
opponentHand = Array.Empty<Piece>();
userHand = Array.Empty<Piece>();
userName = string.Empty;
opponentName = string.Empty;
}
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Session == null)
{
opponentHand = Array.Empty<Piece>();
userHand = Array.Empty<Piece>();
userName = string.Empty;
opponentName = string.Empty;
}
else
{
Console.WriteLine(JsonSerializer.Serialize(new { this.Session.Player1, this.Session.Player2, Perspective, this.Session.SessionName }));
opponentHand = Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player2Hand
: this.Session.BoardState.Player1Hand;
userHand = Perspective == WhichPlayer.Player1
? this.Session.BoardState.Player1Hand
: this.Session.BoardState.Player2Hand;
userName = Perspective == WhichPlayer.Player1
? this.Session.Player1.Name
: this.Session.Player2?.Name ?? "Empty Seat";
opponentName = Perspective == WhichPlayer.Player1
? this.Session.Player2?.Name ?? "Empty Seat"
: this.Session.Player1.Name;
}
}
private Action OnClickTileInternal(Piece? piece, string position) => () => OnClickTile?.Invoke(piece, position);
private Action OnClickHandInternal(Piece piece) => () => OnClickHand?.Invoke(piece);
private void OnClickJoinGameInternal() => OnClickJoinGame?.Invoke();
}

View File

@@ -1,143 +0,0 @@
.game-board {
display: grid;
/*grid-template-areas: "board side-board icons";
grid-template-columns: 1fr minmax(9rem, 15rem) 3rem;*/
grid-template-areas: "board";
grid-template-columns: 1fr;
place-content: center;
padding: 1rem;
gap: 0.5rem;
background-color: #444;
position: relative; /* For absolute positioned children. */
}
.board {
grid-area: board;
}
.side-board {
grid-area: side-board;
}
.icons {
grid-area: icons;
}
.board {
position: relative;
display: grid;
grid-template-areas:
"rank A9 B9 C9 D9 E9 F9 G9 H9 I9"
"rank A8 B8 C8 D8 E8 F8 G8 H8 I8"
"rank A7 B7 C7 D7 E7 F7 G7 H7 I7"
"rank A6 B6 C6 D6 E6 F6 G6 H6 I6"
"rank A5 B5 C5 D5 E5 F5 G5 H5 I5"
"rank A4 B4 C4 D4 E4 F4 G4 H4 I4"
"rank A3 B3 C3 D3 E3 F3 G3 H3 I3"
"rank A2 B2 C2 D2 E2 F2 G2 H2 I2"
"rank A1 B1 C1 D1 E1 F1 G1 H1 I1"
". file file file file file file file file file";
grid-template-columns: auto repeat(9, 1fr);
grid-template-rows: repeat(9, 1fr) auto;
gap: 3px;
aspect-ratio: 0.9167;
max-height: calc(100vh - 2rem);
}
.board[data-perspective="Player2"] {
grid-template-areas:
"file file file file file file file file file ."
"I1 H1 G1 F1 E1 D1 C1 B1 A1 rank"
"I2 H2 G2 F2 E2 D2 C2 B2 A2 rank"
"I3 H3 G3 F3 E3 D3 C3 B3 A3 rank"
"I4 H4 G4 F4 E4 D4 C4 B4 A4 rank"
"I5 H5 G5 F5 E5 D5 C5 B5 A5 rank"
"I6 H6 G6 F6 E6 D6 C6 B6 A6 rank"
"I7 H7 G7 F7 E7 D7 C7 B7 A7 rank"
"I8 H8 G8 F8 E8 D8 C8 B8 A8 rank"
"I9 H9 G9 F9 E9 D9 C9 B9 A9 rank";
grid-template-columns: repeat(9, minmax(0, 1fr)) auto;
grid-template-rows: auto repeat(9, minmax(0, 1fr));
}
.tile {
display: grid;
place-content: center;
transition: filter linear 0.25s;
aspect-ratio: 0.9167;
}
.board .tile {
background-color: beige;
}
.tile[data-selected] {
filter: invert(0.8);
}
.ruler {
color: beige;
display: flex;
flex-direction: row;
justify-content: space-around;
}
.ruler.vertical {
flex-direction: column;
}
.board[data-perspective="Player2"] .ruler {
flex-direction: row-reverse;
}
.board[data-perspective="Player2"] .ruler.vertical {
flex-direction: column-reverse;
}
.side-board {
display: flex;
flex-direction: column;
place-content: space-between;
padding: 1rem;
background-color: var(--contrast-color);
}
.side-board .player-area {
display: grid;
place-items: stretch;
}
.side-board .hand {
display: grid;
border: 1px solid #ccc;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: 4rem;
place-items: center start;
padding: 0.5rem;
}
.side-board .hand .tile {
max-height: 100%; /* I have no idea why I need to set this here to prevent a height blowout. */
background-color: var(--secondary-color);
}
.promote-prompt {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 2px solid #444;
background-color: #eaeaea;
padding: 1rem;
box-shadow: 1px 1px 1px #444;
text-align: center;
}
.promote-prompt[data-visible="true"] {
display: block;
}
.spectating {
color: var(--contrast-color)
}

View File

@@ -1,121 +0,0 @@
@using Shogi.Contracts.Api;
@using Shogi.Contracts.Types;
@using System.Text.RegularExpressions;
@using System.Net;
@inject PromotePrompt PromotePrompt;
@inject IShogiApi ShogiApi;
<GameBoardPresentation Session="Session"
Perspective="Perspective"
OnClickHand="OnClickHand"
OnClickTile="OnClickTile"
SelectedPosition="@selectedBoardPosition"
SelectedPieceFromHand="@selectedPieceFromHand"
IsMyTurn="IsMyTurn" />
@code {
[Parameter, EditorRequired]
public WhichPlayer Perspective { get; set; }
[Parameter, EditorRequired]
public Session Session { get; set; }
private bool IsMyTurn => Session?.BoardState.WhoseTurn == Perspective;
private string? selectedBoardPosition;
private WhichPiece? selectedPieceFromHand;
protected override void OnParametersSet()
{
base.OnParametersSet();
selectedBoardPosition = null;
selectedPieceFromHand = null;
if (Session == null)
{
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
}
}
bool ShouldPromptForPromotion(string position)
{
if (Perspective == WhichPlayer.Player1 && Regex.IsMatch(position, ".[7-9]"))
{
return true;
}
if (Perspective == WhichPlayer.Player2 && Regex.IsMatch(position, ".[1-3]"))
{
return true;
}
return false;
}
async Task OnClickTile(Piece? pieceAtPosition, string position)
{
if (!IsMyTurn) return;
if (selectedBoardPosition == position)
{
// Deselect the selected position.
selectedBoardPosition = null;
StateHasChanged();
return;
}
if (selectedBoardPosition == null && pieceAtPosition?.Owner == Perspective)
{
// Select an owned piece.
Console.WriteLine("Selecting piece owned by {0} while I am perspective {1}", pieceAtPosition?.Owner, Perspective);
selectedBoardPosition = position;
// Prevent selecting pieces from the hand and board at the same time.
selectedPieceFromHand = null;
StateHasChanged();
return;
}
if (selectedPieceFromHand is not null)
{
if (pieceAtPosition is null)
{
// Placing a piece from the hand to an empty space.
await ShogiApi.Move(
Session.SessionName,
new MovePieceCommand(selectedPieceFromHand.Value, position));
}
StateHasChanged();
return;
}
if (selectedBoardPosition != null)
{
if (pieceAtPosition == null || pieceAtPosition?.Owner != Perspective)
{
// Moving to an empty space or capturing an opponent's piece.
if (ShouldPromptForPromotion(position) || ShouldPromptForPromotion(selectedBoardPosition))
{
PromotePrompt.Show(
Session.SessionName,
new MovePieceCommand(selectedBoardPosition, position, false));
}
else
{
await ShogiApi.Move(Session.SessionName, new MovePieceCommand(selectedBoardPosition, position, false));
}
StateHasChanged();
return;
}
}
}
async Task OnClickHand(Piece piece)
{
if (!IsMyTurn) return;
// Prevent selecting from both the hand and the board.
selectedBoardPosition = null;
selectedPieceFromHand = piece.WhichPiece == selectedPieceFromHand
// Deselecting the already-selected piece
? selectedPieceFromHand = null
: selectedPieceFromHand = piece.WhichPiece;
StateHasChanged();
}
}

View File

@@ -1,27 +0,0 @@
@using Contracts.Types;
@using System.Net;
@inject IShogiApi ShogiApi;
<GameBoardPresentation IsSpectating="true"
Perspective="WhichPlayer.Player2"
Session="Session"
OnClickJoinGame="OnClickJoinGame" />
@code {
[Parameter] public Session Session { get; set; }
protected override void OnParametersSet()
{
base.OnParametersSet();
if (Session == null)
{
throw new ArgumentException($"{nameof(Session)} cannot be null.", nameof(Session));
}
}
async Task OnClickJoinGame()
{
var response = await ShogiApi.PatchJoinGame(Session.SessionName);
response.EnsureSuccessStatusCode();
}
}

Some files were not shown because too many files have changed in this diff Show More