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:
6
Shogi.Api/ApiKeys.cs
Normal file
6
Shogi.Api/ApiKeys.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Shogi.Api;
|
||||
|
||||
public class ApiKeys
|
||||
{
|
||||
public string BrevoEmailService { get; set; } = string.Empty;
|
||||
}
|
||||
19
Shogi.Api/Application/GameHub.cs
Normal file
19
Shogi.Api/Application/GameHub.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
21
Shogi.Api/Application/GameHubContext.cs
Normal file
21
Shogi.Api/Application/GameHubContext.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
143
Shogi.Api/Application/ShogiApplication.cs
Normal file
143
Shogi.Api/Application/ShogiApplication.cs
Normal 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!;
|
||||
}
|
||||
}
|
||||
74
Shogi.Api/Controllers/AccountController.cs
Normal file
74
Shogi.Api/Controllers/AccountController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
11
Shogi.Api/Controllers/Extentions.cs
Normal file
11
Shogi.Api/Controllers/Extentions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Shogi.Api/Identity/ApplicationDbContext.cs
Normal file
8
Shogi.Api/Identity/ApplicationDbContext.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
7
Shogi.Api/Identity/ShogiUser.cs
Normal file
7
Shogi.Api/Identity/ShogiUser.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Shogi.Api.Identity;
|
||||
|
||||
public class ShogiUser : IdentityUser
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
279
Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs
generated
Normal file
279
Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
224
Shogi.Api/Migrations/20240816002834_InitialCreate.cs
Normal file
224
Shogi.Api/Migrations/20240816002834_InitialCreate.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
276
Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs
Normal file
276
Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Shogi.Api.Models
|
||||
{
|
||||
public enum WhichLoginPlatform
|
||||
{
|
||||
Unknown,
|
||||
Microsoft,
|
||||
Guest
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
54
Shogi.Api/Repositories/EmailSender.cs
Normal file
54
Shogi.Api/Repositories/EmailSender.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Shogi.Contracts.Api;
|
||||
|
||||
public class CreateSessionCommand
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using Shogi.Contracts.Types;
|
||||
|
||||
namespace Shogi.Contracts.Api;
|
||||
|
||||
public class ReadSessionResponse
|
||||
{
|
||||
public Session Session { get; set; }
|
||||
}
|
||||
@@ -10,4 +10,8 @@
|
||||
<Description>Contains DTOs use for http requests to Shogi backend services.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Api\Queries\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using Shogi.Contracts.Types;
|
||||
|
||||
namespace Shogi.Contracts.Socket;
|
||||
|
||||
public class SessionCreatedSocketMessage : ISocketMessage
|
||||
{
|
||||
public SocketAction Action => SocketAction.SessionCreated;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Shogi.Contracts.Types
|
||||
{
|
||||
public enum SocketAction
|
||||
{
|
||||
SessionCreated,
|
||||
SessionJoined,
|
||||
PieceMoved
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
3
Shogi.Database/AspNetUsersId.sql
Normal file
3
Shogi.Database/AspNetUsersId.sql
Normal 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;
|
||||
@@ -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
|
||||
*/
|
||||
@@ -10,6 +10,5 @@ Post-Deployment Script Template
|
||||
--------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
:r .\Scripts\PopulateLoginPlatforms.sql
|
||||
:r .\Scripts\PopulatePieces.sql
|
||||
:r .\Scripts\EnableSnapshotIsolationLevel.sql
|
||||
@@ -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]);
|
||||
18
Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql
Normal file
18
Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql
Normal 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
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
CREATE TYPE [session].[SessionName]
|
||||
FROM nvarchar(50) NOT NULL
|
||||
2
Shogi.Database/Session/Types/SessionSurrogateKey.sql
Normal file
2
Shogi.Database/Session/Types/SessionSurrogateKey.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
CREATE TYPE [session].[SessionSurrogateKey]
|
||||
FROM CHAR(36) NOT NULL
|
||||
17
Shogi.Database/Shogi.Database.refactorlog
Normal file
17
Shogi.Database/Shogi.Database.refactorlog
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
CREATE TABLE [user].[LoginPlatform]
|
||||
(
|
||||
[Platform] NVARCHAR(20) NOT NULL PRIMARY KEY
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
CREATE TYPE [user].[UserName]
|
||||
FROM nvarchar(100) NOT NULL
|
||||
@@ -1 +0,0 @@
|
||||
CREATE SCHEMA [user]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
246
Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
Normal file
246
Shogi.UI/Identity/CookieAuthenticationStateProvider.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
14
Shogi.UI/Identity/CookieMessageHandler.cs
Normal file
14
Shogi.UI/Identity/CookieMessageHandler.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
Shogi.UI/Identity/FormResult.cs
Normal file
14
Shogi.UI/Identity/FormResult.cs
Normal 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; } = [];
|
||||
}
|
||||
31
Shogi.UI/Identity/IAccountManagement.cs
Normal file
31
Shogi.UI/Identity/IAccountManagement.cs
Normal 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();
|
||||
}
|
||||
22
Shogi.UI/Identity/UserInfo.cs
Normal file
22
Shogi.UI/Identity/UserInfo.cs
Normal 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; } = [];
|
||||
}
|
||||
7
Shogi.UI/Layout/MainLayout.razor
Normal file
7
Shogi.UI/Layout/MainLayout.razor
Normal file
@@ -0,0 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="MainLayout PrimaryTheme">
|
||||
<NavMenu />
|
||||
@Body
|
||||
</div>
|
||||
|
||||
5
Shogi.UI/Layout/MainLayout.razor.css
Normal file
5
Shogi.UI/Layout/MainLayout.razor.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.MainLayout {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
place-items: stretch;
|
||||
}
|
||||
52
Shogi.UI/Layout/NavMenu.razor
Normal file
52
Shogi.UI/Layout/NavMenu.razor
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
15
Shogi.UI/Layout/NavMenu.razor.css
Normal file
15
Shogi.UI/Layout/NavMenu.razor.css
Normal 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;
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@page "/error"
|
||||
|
||||
<main>
|
||||
<main class="PrimaryTheme">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Oops!</h1>
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Shogi.UI.Pages.Home.Account;
|
||||
|
||||
public class LoginEventArgs : EventArgs
|
||||
{
|
||||
public User? User { get; set; }
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Shogi.UI.Pages.Home.Account
|
||||
{
|
||||
public readonly record struct User(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
WhichAccountPlatform WhichAccountPlatform)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace Shogi.UI.Pages.Home.Account
|
||||
{
|
||||
public enum WhichAccountPlatform
|
||||
{
|
||||
Guest,
|
||||
Microsoft
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user