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.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Shogi.Api.Application;
|
||||||
using Shogi.Api.Extensions;
|
using Shogi.Api.Extensions;
|
||||||
using Shogi.Api.Managers;
|
using Shogi.Api.Identity;
|
||||||
using Shogi.Api.Repositories;
|
using Shogi.Api.Repositories;
|
||||||
using Shogi.Contracts.Api;
|
using Shogi.Contracts.Api;
|
||||||
using Shogi.Contracts.Socket;
|
|
||||||
using Shogi.Contracts.Types;
|
using Shogi.Contracts.Types;
|
||||||
using System.Data.SqlClient;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Shogi.Api.Controllers;
|
namespace Shogi.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("[controller]")]
|
[Route("[controller]")]
|
||||||
[Authorize]
|
public class SessionsController(
|
||||||
public class SessionsController : ControllerBase
|
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(
|
[HttpPost]
|
||||||
ISocketConnectionManager communicationManager,
|
public async Task<IActionResult> CreateSession()
|
||||||
IModelMapper mapper,
|
{
|
||||||
ISessionRepository sessionRepository,
|
var id = this.User.GetId();
|
||||||
IQueryRespository queryRespository,
|
if (string.IsNullOrEmpty(id))
|
||||||
ILogger<SessionsController> logger)
|
{
|
||||||
{
|
return this.Unauthorized();
|
||||||
this.communicationManager = communicationManager;
|
}
|
||||||
this.mapper = mapper;
|
return await application.CreateSession(id);
|
||||||
this.sessionRepository = sessionRepository;
|
}
|
||||||
this.queryRespository = queryRespository;
|
|
||||||
this.logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpDelete("{sessionId}")]
|
||||||
public async Task<IActionResult> CreateSession([FromBody] CreateSessionCommand request)
|
public async Task<IActionResult> DeleteSession(string sessionId)
|
||||||
{
|
{
|
||||||
var userId = User.GetShogiUserId();
|
var id = this.User.GetId();
|
||||||
var session = new Domain.Session(request.Name, userId);
|
if (id == null)
|
||||||
try
|
{
|
||||||
{
|
return this.Unauthorized();
|
||||||
await sessionRepository.CreateSession(session);
|
}
|
||||||
}
|
|
||||||
catch (SqlException e)
|
|
||||||
{
|
|
||||||
logger.LogError(exception: e, message: "Uh oh");
|
|
||||||
return this.Conflict();
|
|
||||||
}
|
|
||||||
|
|
||||||
await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage());
|
var (session, _) = await sessionRepository.ReadSessionAndMoves(sessionId);
|
||||||
return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null);
|
if (!session.HasValue) return this.NoContent();
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{name}")]
|
if (session.Value.Player1Id == id)
|
||||||
public async Task<IActionResult> DeleteSession(string name)
|
{
|
||||||
{
|
await sessionRepository.DeleteSession(sessionId);
|
||||||
var userId = User.GetShogiUserId();
|
return this.NoContent();
|
||||||
var session = await sessionRepository.ReadSession(name);
|
}
|
||||||
|
|
||||||
if (session == null) return this.NoContent();
|
return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others.");
|
||||||
|
}
|
||||||
|
|
||||||
if (session.Player1 == userId)
|
/// <summary>
|
||||||
{
|
/// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
|
||||||
await sessionRepository.DeleteSession(name);
|
/// </summary>
|
||||||
return this.NoContent();
|
/// <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")]
|
[HttpGet()]
|
||||||
public async Task<ActionResult<ReadSessionsPlayerCountResponse>> GetSessionsPlayerCount()
|
public async Task<ActionResult<SessionMetadata[]>> ReadAllSessionsMetadata()
|
||||||
{
|
{
|
||||||
return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId()));
|
var id = this.User.GetId();
|
||||||
}
|
if (id == null) return this.Unauthorized();
|
||||||
|
|
||||||
/// <summary>
|
var dtos = await application.ReadAllSessionMetadatas(id);
|
||||||
/// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
|
return dtos
|
||||||
/// </summary>
|
.Select(dto => new SessionMetadata
|
||||||
/// <param name="name"></param>
|
{
|
||||||
/// <returns></returns>
|
Player1 = application.GetUsername(dto.Player1Id),
|
||||||
[HttpGet("{name}")]
|
Player2 = application.GetUsername(dto.Player2Id),
|
||||||
public async Task<ActionResult<ReadSessionResponse>> GetSession(string name)
|
SessionId = Guid.Parse(dto.Id),
|
||||||
{
|
})
|
||||||
var session = await sessionRepository.ReadSession(name);
|
.ToArray();
|
||||||
if (session == null) return this.NotFound();
|
}
|
||||||
|
|
||||||
var players = await queryRespository.GetUsersForSession(session.Name);
|
[HttpPatch("{sessionId}/Join")]
|
||||||
if (players == null) return this.NotFound();
|
public async Task<IActionResult> JoinSession(string sessionId)
|
||||||
|
{
|
||||||
|
var id = this.User.GetId();
|
||||||
|
if (id == null)
|
||||||
|
{
|
||||||
|
return this.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
return new ReadSessionResponse
|
return await application.JoinSession(sessionId, id);
|
||||||
{
|
}
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{name}/Join")]
|
[HttpPatch("{sessionId}/Move")]
|
||||||
public async Task<IActionResult> JoinSession(string name)
|
public async Task<IActionResult> Move([FromRoute] string sessionId, [FromBody] MovePieceCommand command)
|
||||||
{
|
{
|
||||||
var session = await sessionRepository.ReadSession(name);
|
var id = this.User.GetId();
|
||||||
if (session == null) return this.NotFound();
|
if (id == null)
|
||||||
|
{
|
||||||
|
return this.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(session.Player2))
|
return await application.MovePiece(id, sessionId, command);
|
||||||
{
|
}
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Identity.UI.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
using Shogi.Api;
|
||||||
using Microsoft.AspNetCore.Http.Json;
|
using Shogi.Api.Application;
|
||||||
using Microsoft.AspNetCore.HttpLogging;
|
using Shogi.Api.Identity;
|
||||||
using Microsoft.Identity.Web;
|
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using Shogi.Api.Managers;
|
|
||||||
using Shogi.Api.Repositories;
|
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
|
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
|
||||||
{
|
});
|
||||||
public static void Main(string[] args)
|
var app = builder.Build();
|
||||||
{
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? throw new InvalidOperationException("Configuration for allowed origins is missing.");
|
app.MapIdentityApi<ShogiUser>();
|
||||||
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);
|
|
||||||
|
|
||||||
var app = builder.Build();
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
app.UseWhen(
|
app.UseHttpsRedirection(); // Apache handles HTTPS in production.
|
||||||
// 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>()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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;
|
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 Dapper;
|
||||||
using Shogi.Contracts.Api;
|
using Shogi.Api.Repositories.Dto;
|
||||||
using Shogi.Contracts.Types;
|
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Data.SqlClient;
|
using System.Data.SqlClient;
|
||||||
|
|
||||||
namespace Shogi.Api.Repositories;
|
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)
|
public async Task<IEnumerable<SessionDto>> ReadSessionsMetadata(string playerId)
|
||||||
{
|
{
|
||||||
var connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("No database configured for QueryRepository.");
|
using var connection = new SqlConnection(this.connectionString);
|
||||||
this.connectionString = connectionString;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName)
|
var results = await connection.QueryMultipleAsync(
|
||||||
{
|
"session.ReadSessionsMetadata",
|
||||||
using var connection = new SqlConnection(connectionString);
|
new { PlayerId = playerId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
|
||||||
var results = await connection.QueryMultipleAsync(
|
return await results.ReadAsync<SessionDto>();
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IQueryRespository
|
|
||||||
{
|
|
||||||
Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName);
|
|
||||||
Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName);
|
|
||||||
}
|
|
||||||
@@ -1,120 +1,84 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Shogi.Api.Repositories.Dto;
|
using Shogi.Api.Repositories.Dto;
|
||||||
using Shogi.Contracts.Api;
|
using Shogi.Contracts.Api;
|
||||||
using Shogi.Domain;
|
using Shogi.Domain.Aggregates;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Data.SqlClient;
|
using System.Data.SqlClient;
|
||||||
|
|
||||||
namespace Shogi.Api.Repositories;
|
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)
|
public async Task CreateSession(Session session)
|
||||||
{
|
{
|
||||||
connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database connection string not configured.");
|
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)
|
public async Task DeleteSession(string id)
|
||||||
{
|
{
|
||||||
using var connection = new SqlConnection(connectionString);
|
using var connection = new SqlConnection(this.connectionString);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"session.CreateSession",
|
"session.DeleteSession",
|
||||||
new
|
new { Id = id },
|
||||||
{
|
commandType: CommandType.StoredProcedure);
|
||||||
session.Name,
|
}
|
||||||
Player1Name = session.Player1,
|
|
||||||
},
|
|
||||||
commandType: CommandType.StoredProcedure);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteSession(string name)
|
public async Task<(SessionDto? Session, IEnumerable<MoveDto> Moves)> ReadSessionAndMoves(string id)
|
||||||
{
|
{
|
||||||
using var connection = new SqlConnection(connectionString);
|
using var connection = new SqlConnection(this.connectionString);
|
||||||
await connection.ExecuteAsync(
|
var results = await connection.QueryMultipleAsync(
|
||||||
"session.DeleteSession",
|
"session.ReadSession",
|
||||||
new { Name = name },
|
new { Id = id },
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Session?> ReadSession(string name)
|
var sessionDtos = await results.ReadAsync<SessionDto>();
|
||||||
{
|
if (!sessionDtos.Any())
|
||||||
using var connection = new SqlConnection(connectionString);
|
{
|
||||||
var results = await connection.QueryMultipleAsync(
|
return (null, []);
|
||||||
"session.ReadSession",
|
}
|
||||||
new { Name = name },
|
|
||||||
commandType: CommandType.StoredProcedure);
|
|
||||||
|
|
||||||
var sessionDtos = await results.ReadAsync<SessionDto>();
|
var moveDtos = await results.ReadAsync<MoveDto>();
|
||||||
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>();
|
return new(sessionDtos.First(), moveDtos);
|
||||||
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 CreateMove(string sessionName, MovePieceCommand command)
|
public async Task CreateMove(string sessionId, MovePieceCommand command)
|
||||||
{
|
{
|
||||||
var yep = new
|
using var connection = new SqlConnection(this.connectionString);
|
||||||
{
|
await connection.ExecuteAsync(
|
||||||
command.To,
|
"session.CreateMove",
|
||||||
command.From,
|
new
|
||||||
command.IsPromotion,
|
{
|
||||||
command.PieceFromHand,
|
command.To,
|
||||||
SessionName = sessionName
|
command.From,
|
||||||
};
|
command.IsPromotion,
|
||||||
|
PieceFromHand = command.PieceFromHand.ToString(),
|
||||||
|
SessionId = sessionId
|
||||||
|
},
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
using var connection = new SqlConnection(connectionString);
|
public async Task SetPlayer2(string sessionId, string player2Id)
|
||||||
await connection.ExecuteAsync(
|
{
|
||||||
"session.CreateMove",
|
using var connection = new SqlConnection(this.connectionString);
|
||||||
new
|
await connection.ExecuteAsync(
|
||||||
{
|
"session.SetPlayer2",
|
||||||
command.To,
|
new
|
||||||
command.From,
|
{
|
||||||
command.IsPromotion,
|
SessionId = sessionId,
|
||||||
PieceFromHand = command.PieceFromHand.ToString(),
|
PlayerId = player2Id
|
||||||
SessionName = sessionName
|
},
|
||||||
},
|
commandType: CommandType.StoredProcedure);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
<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="Dapper" Version="2.1.28" />
|
||||||
<PackageReference Include="FluentValidation" Version="11.9.0" />
|
<PackageReference Include="FluentValidation" Version="11.9.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||||
<PackageReference Include="Microsoft.Identity.Web" Version="2.17.0" />
|
<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="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
|
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
|
||||||
</ItemGroup>
|
</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": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft": "Warning",
|
"Microsoft": "Warning",
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"ApiKeys": {
|
||||||
|
"BrevoEmailService": "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"
|
||||||
|
},
|
||||||
|
"TestUserPassword": "I'mAToysRUsK1d"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
|
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning",
|
"Default": "Warning",
|
||||||
"Microsoft": "Warning",
|
"Microsoft": "Warning",
|
||||||
"Microsoft.Hosting.Lifetime": "Error",
|
"Microsoft.Hosting.Lifetime": "Error",
|
||||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
|
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
|
||||||
"System.Net.Http.HttpClient": "Error"
|
"System.Net.Http.HttpClient": "Error"
|
||||||
}
|
//"Microsoft.AspNetCore.SignalR": "Debug",
|
||||||
},
|
//"Microsoft.AspNetCore.Http.Connections": "Debug"
|
||||||
"AzureAd": {
|
}
|
||||||
"Instance": "https://login.microsoftonline.com/",
|
},
|
||||||
"TenantId": "common",
|
"ApiKeys": {
|
||||||
"ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff",
|
"BrevoEmailService": ""
|
||||||
"SwaggerUIClientId": "26bf69a4-2af8-4711-bf5b-79f75e20b082",
|
},
|
||||||
"Scope": "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope"
|
"Cors": {
|
||||||
},
|
"AllowedOrigins": [
|
||||||
"Cors": {
|
"http://localhost:3000",
|
||||||
"AllowedOrigins": [
|
"https://localhost:3000",
|
||||||
"http://localhost:3000",
|
"https://api.lucaserver.space",
|
||||||
"https://localhost:3000",
|
"https://lucaserver.space"
|
||||||
"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>
|
/// </summary>
|
||||||
public MovePieceCommand()
|
public MovePieceCommand()
|
||||||
{
|
{
|
||||||
To = string.Empty;
|
this.To = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -20,9 +20,9 @@ public class MovePieceCommand : IValidatableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MovePieceCommand(string from, string to, bool isPromotion)
|
public MovePieceCommand(string from, string to, bool isPromotion)
|
||||||
{
|
{
|
||||||
From = from;
|
this.From = from;
|
||||||
To = to;
|
this.To = to;
|
||||||
IsPromotion = isPromotion;
|
this.IsPromotion = isPromotion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,8 +30,8 @@ public class MovePieceCommand : IValidatableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public MovePieceCommand(WhichPiece pieceFromHand, string to)
|
public MovePieceCommand(WhichPiece pieceFromHand, string to)
|
||||||
{
|
{
|
||||||
PieceFromHand = pieceFromHand;
|
this.PieceFromHand = pieceFromHand;
|
||||||
To = to;
|
this.To = to;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -57,21 +57,21 @@ public class MovePieceCommand : IValidatableObject
|
|||||||
|
|
||||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
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>
|
<Description>Contains DTOs use for http requests to Shogi backend services.</Description>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Api\Queries\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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 class Session
|
||||||
{
|
{
|
||||||
public User Player1 { get; set; }
|
/// <summary>
|
||||||
public User? Player2 { get; set; }
|
/// Email
|
||||||
public string SessionName { get; set; }
|
/// </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; }
|
public BoardState BoardState { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
namespace Shogi.Contracts.Types
|
using System;
|
||||||
|
|
||||||
|
namespace Shogi.Contracts.Types
|
||||||
{
|
{
|
||||||
public class SessionMetadata
|
public class SessionMetadata
|
||||||
{
|
{
|
||||||
public string Name { get; set; }
|
public Guid SessionId { get; set; }
|
||||||
public int PlayerCount { 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
|
--CREATE ROLE db_executor
|
||||||
--GRANT EXECUTE To 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\PopulatePieces.sql
|
||||||
:r .\Scripts\EnableSnapshotIsolationLevel.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]
|
CREATE PROCEDURE [session].[CreateMove]
|
||||||
@To VARCHAR(2),
|
@To VARCHAR(2),
|
||||||
@From VARCHAR(2) = NULL,
|
@From VARCHAR(2) = NULL,
|
||||||
@IsPromotion BIT = 0,
|
@IsPromotion BIT = 0,
|
||||||
@PieceFromHand NVARCHAR(13) = NULL,
|
@PieceFromHand NVARCHAR(13) = NULL,
|
||||||
@SessionName [session].[SessionName]
|
@SessionId [session].[SessionSurrogateKey]
|
||||||
AS
|
AS
|
||||||
|
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -13,11 +13,6 @@ BEGIN
|
|||||||
|
|
||||||
BEGIN TRANSACTION
|
BEGIN TRANSACTION
|
||||||
|
|
||||||
DECLARE @SessionId BIGINT = 0;
|
|
||||||
SELECT @SessionId = Id
|
|
||||||
FROM [session].[Session]
|
|
||||||
WHERE [Name] = @SessionName;
|
|
||||||
|
|
||||||
DECLARE @PieceIdFromhand INT = NULL;
|
DECLARE @PieceIdFromhand INT = NULL;
|
||||||
SELECT @PieceIdFromhand = Id
|
SELECT @PieceIdFromhand = Id
|
||||||
FROM [session].[Piece]
|
FROM [session].[Piece]
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
CREATE PROCEDURE [session].[CreateSession]
|
CREATE PROCEDURE [session].[CreateSession]
|
||||||
@Name [session].[SessionName],
|
@Id [session].[SessionSurrogateKey],
|
||||||
@Player1Name [user].[UserName]
|
@Player1Id [dbo].[AspNetUsersId]
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON
|
SET NOCOUNT ON
|
||||||
|
|
||||||
INSERT INTO [session].[Session] ([Name], Player1Id)
|
INSERT INTO [session].[Session]
|
||||||
SELECT @Name, Id
|
([Id], Player1Id)
|
||||||
FROM [user].[User]
|
VALUES
|
||||||
WHERE [Name] = @Player1Name
|
(@Id, @Player1Id)
|
||||||
|
|
||||||
END
|
END
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
CREATE PROCEDURE [session].[DeleteSession]
|
CREATE PROCEDURE [session].[DeleteSession]
|
||||||
@Name [session].[SessionName]
|
@Id [session].[SessionSurrogateKey]
|
||||||
AS
|
AS
|
||||||
|
|
||||||
DELETE FROM [session].[Session] WHERE [Name] = @Name;
|
DELETE FROM [session].[Session] WHERE [Id] = @Id;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
CREATE PROCEDURE [session].[ReadSession]
|
CREATE PROCEDURE [session].[ReadSession]
|
||||||
@Name [session].[SessionName]
|
@Id [session].[SessionSurrogateKey]
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON -- Performance boost
|
SET NOCOUNT ON -- Performance boost
|
||||||
@@ -10,13 +10,12 @@ BEGIN
|
|||||||
|
|
||||||
-- Session
|
-- Session
|
||||||
SELECT
|
SELECT
|
||||||
sess.[Name],
|
Id,
|
||||||
p1.[Name] as Player1,
|
Player1Id,
|
||||||
p2.[Name] as Player2
|
Player2Id,
|
||||||
FROM [session].[Session] sess
|
CreatedDate
|
||||||
INNER JOIN [user].[User] p1 on sess.Player1Id = p1.Id
|
FROM [session].[Session]
|
||||||
LEFT JOIN [user].[User] p2 on sess.Player2Id = p2.Id
|
WHERE Id = @Id;
|
||||||
WHERE sess.[Name] = @Name;
|
|
||||||
|
|
||||||
-- Player moves
|
-- Player moves
|
||||||
SELECT
|
SELECT
|
||||||
@@ -27,7 +26,7 @@ BEGIN
|
|||||||
FROM [session].[Move] mv
|
FROM [session].[Move] mv
|
||||||
INNER JOIN [session].[Session] sess ON sess.Id = mv.SessionId
|
INNER JOIN [session].[Session] sess ON sess.Id = mv.SessionId
|
||||||
LEFT JOIN [session].Piece piece on piece.Id = mv.PieceIdFromHand
|
LEFT JOIN [session].Piece piece on piece.Id = mv.PieceIdFromHand
|
||||||
WHERE sess.[Name] = @Name;
|
WHERE sess.[Id] = @Id;
|
||||||
|
|
||||||
COMMIT
|
COMMIT
|
||||||
END
|
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]
|
CREATE PROCEDURE [session].[SetPlayer2]
|
||||||
@SessionName [session].[SessionName],
|
@SessionId [session].[SessionSurrogateKey],
|
||||||
@Player2Name [user].[UserName] NULL
|
@PlayerId [dbo].[AspNetUsersId]
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
DECLARE @player2Id BIGINT;
|
UPDATE [session].[Session]
|
||||||
SELECT @player2Id = Id FROM [user].[User] WHERE [Name] = @Player2Name;
|
SET Player2Id = @PlayerId
|
||||||
|
FROM [session].[Session]
|
||||||
UPDATE sess
|
WHERE Id = @SessionId;
|
||||||
SET Player2Id = @player2Id
|
|
||||||
FROM [session].[Session] sess
|
|
||||||
WHERE sess.[Name] = @SessionName;
|
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
CREATE TABLE [session].[Move]
|
CREATE TABLE [session].[Move]
|
||||||
(
|
(
|
||||||
[Id] INT NOT NULL PRIMARY KEY IDENTITY,
|
[Id] INT NOT NULL PRIMARY KEY IDENTITY,
|
||||||
[SessionId] BIGINT NOT NULL,
|
[SessionId] [session].[SessionSurrogateKey] NOT NULL,
|
||||||
[To] VARCHAR(2) NOT NULL,
|
[To] VARCHAR(2) NOT NULL,
|
||||||
[From] VARCHAR(2) NULL,
|
[From] VARCHAR(2) NULL,
|
||||||
[PieceIdFromHand] INT NULL,
|
[PieceIdFromHand] INT NULL,
|
||||||
[IsPromotion] BIT DEFAULT 0
|
[IsPromotion] BIT DEFAULT 0
|
||||||
|
|
||||||
CONSTRAINT [Cannot end where you start]
|
CONSTRAINT [Cannot end where you start]
|
||||||
CHECK ([From] <> [To]),
|
CHECK ([From] <> [To]),
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
CREATE TABLE [session].[Session]
|
CREATE TABLE [session].[Session]
|
||||||
(
|
(
|
||||||
Id BIGINT NOT NULL PRIMARY KEY IDENTITY,
|
Id [session].[SessionSurrogateKey] PRIMARY KEY,
|
||||||
[Name] [session].[SessionName] UNIQUE,
|
Player1Id [dbo].[AspNetUsersId] NOT NULL,
|
||||||
Player1Id BIGINT NOT NULL,
|
Player2Id [dbo].[AspNetUsersId] NULL,
|
||||||
Player2Id BIGINT NULL,
|
[CreatedDate] DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
|
||||||
Created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
|
CONSTRAINT [CK_Session_LimitedNewSessions] CHECK ([session].MaxNewSessionsPerUser() < 4),
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
<ItemGroup>
|
||||||
<Folder Include="Properties" />
|
<Folder Include="Properties" />
|
||||||
<Folder Include="Session" />
|
<Folder Include="Session" />
|
||||||
<Folder Include="User" />
|
|
||||||
<Folder Include="Session\Tables" />
|
<Folder Include="Session\Tables" />
|
||||||
<Folder Include="Session\Stored Procedures" />
|
<Folder Include="Session\Stored Procedures" />
|
||||||
<Folder Include="User\Tables" />
|
|
||||||
<Folder Include="Session\Types" />
|
<Folder Include="Session\Types" />
|
||||||
<Folder Include="User\Types" />
|
|
||||||
<Folder Include="User\StoredProcedures" />
|
|
||||||
<Folder Include="Post Deployment" />
|
<Folder Include="Post Deployment" />
|
||||||
<Folder Include="Post Deployment\Scripts" />
|
<Folder Include="Post Deployment\Scripts" />
|
||||||
|
<Folder Include="Session\Functions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Build Include="Session\session.sql" />
|
<Build Include="Session\session.sql" />
|
||||||
<Build Include="User\user.sql" />
|
|
||||||
<Build Include="Session\Tables\Session.sql" />
|
<Build Include="Session\Tables\Session.sql" />
|
||||||
<Build Include="Session\Stored Procedures\CreateSession.sql" />
|
<Build Include="Session\Stored Procedures\CreateSession.sql" />
|
||||||
<Build Include="User\Tables\User.sql" />
|
<Build Include="Session\Types\SessionSurrogateKey.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\Stored Procedures\SetPlayer2.sql" />
|
<Build Include="Session\Stored Procedures\SetPlayer2.sql" />
|
||||||
<Build Include="Session\Stored Procedures\ReadSession.sql" />
|
<Build Include="Session\Stored Procedures\ReadSession.sql" />
|
||||||
<Build Include="Session\Tables\Move.sql" />
|
<Build Include="Session\Tables\Move.sql" />
|
||||||
<Build Include="Session\Tables\Piece.sql" />
|
<Build Include="Session\Tables\Piece.sql" />
|
||||||
<Build Include="Session\Stored Procedures\DeleteSession.sql" />
|
<Build Include="Session\Stored Procedures\DeleteSession.sql" />
|
||||||
<Build Include="Session\Stored Procedures\CreateMove.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>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PostDeploy Include="Post Deployment\Script.PostDeployment.sql" />
|
<PostDeploy Include="Post Deployment\Script.PostDeployment.sql" />
|
||||||
@@ -97,4 +88,7 @@
|
|||||||
<None Include="Post Deployment\Scripts\EnableSnapshotIsolationLevel.sql" />
|
<None Include="Post Deployment\Scripts\EnableSnapshotIsolationLevel.sql" />
|
||||||
<None Include="FirstTimeSetup.sql" />
|
<None Include="FirstTimeSetup.sql" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<RefactorLog Include="Shogi.Database.refactorlog" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</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;
|
using Shogi.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Shogi.Domain;
|
namespace Shogi.Domain.Aggregates;
|
||||||
|
|
||||||
public class Session
|
public class Session(Guid id, string player1Name)
|
||||||
{
|
{
|
||||||
public Session(
|
public Guid Id { get; } = id;
|
||||||
string name,
|
|
||||||
string player1Name)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
Player1 = player1Name;
|
|
||||||
Board = new(BoardState.StandardStarting);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
/// <summary>
|
||||||
/// The User.Id of the player which created the session.
|
/// The email of the second player.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Player1 { get; }
|
public string? Player2 { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
public void AddPlayer2(string player2Name)
|
||||||
/// The User.Id of the second player.
|
{
|
||||||
/// </summary>
|
if (this.Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player.");
|
||||||
public string? Player2 { get; private set; }
|
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)
|
public bool IsSeated(string playerName)
|
||||||
{
|
{
|
||||||
if (Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player.");
|
return this.Player1 == playerName || this.Player2 == playerName;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<Router AppAssembly="@typeof(App).Assembly">
|
<Router AppAssembly="@typeof(App).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
|
||||||
</Found>
|
</Found>
|
||||||
<NotFound>
|
<NotFound>
|
||||||
<PageTitle>Not found</PageTitle>
|
<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"
|
@page "/error"
|
||||||
|
|
||||||
<main>
|
<main class="PrimaryTheme">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h1 class="card-title">Oops!</h1>
|
<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