squash a bunch of commits
This commit is contained in:
18
Shogi.Sockets/.config/dotnet-tools.json
Normal file
18
Shogi.Sockets/.config/dotnet-tools.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "6.0.5",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
},
|
||||
"microsoft.dotnet-msidentity": {
|
||||
"version": "1.0.3",
|
||||
"commands": [
|
||||
"dotnet-msidentity"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
217
Shogi.Sockets/Controllers/SessionController.cs
Normal file
217
Shogi.Sockets/Controllers/SessionController.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using Shogi.Api.Managers;
|
||||
using Shogi.Api.Repositories;
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.Contracts.Socket;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Data.SqlClient;
|
||||
using Shogi.Contracts.Types;
|
||||
using Shogi.Api.Extensions;
|
||||
|
||||
namespace Shogi.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
[Authorize]
|
||||
public class SessionController : ControllerBase
|
||||
{
|
||||
private readonly ISocketConnectionManager communicationManager;
|
||||
private readonly IModelMapper mapper;
|
||||
private readonly ISessionRepository sessionRepository;
|
||||
private readonly IQueryRespository queryRespository;
|
||||
|
||||
public SessionController(
|
||||
ISocketConnectionManager communicationManager,
|
||||
IModelMapper mapper,
|
||||
ISessionRepository sessionRepository,
|
||||
IQueryRespository queryRespository)
|
||||
{
|
||||
this.communicationManager = communicationManager;
|
||||
this.mapper = mapper;
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.queryRespository = queryRespository;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateSession([FromBody] CreateSessionCommand request)
|
||||
{
|
||||
var userId = User.GetShogiUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId)) return this.Unauthorized();
|
||||
var session = new Domain.Session(request.Name, Domain.BoardState.StandardStarting, userId);
|
||||
try
|
||||
{
|
||||
await sessionRepository.CreateSession(session);
|
||||
}
|
||||
catch (SqlException)
|
||||
{
|
||||
return this.Conflict();
|
||||
}
|
||||
|
||||
await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage());
|
||||
return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null);
|
||||
}
|
||||
|
||||
//[HttpPost("{sessionName}/Move")]
|
||||
//public async Task<IActionResult> MovePiece([FromRoute] string sessionName, [FromBody] MovePieceCommand request)
|
||||
//{
|
||||
|
||||
// var user = await gameboardManager.ReadUser(User);
|
||||
// var session = await gameboardRepository.ReadSession(sessionName);
|
||||
// if (session == null)
|
||||
// {
|
||||
// return NotFound();
|
||||
// }
|
||||
// if (user == null || (session.Player1 != user.Id && session.Player2 != user.Id))
|
||||
// {
|
||||
// return Forbid("User is not seated at this game.");
|
||||
// }
|
||||
|
||||
// try
|
||||
// {
|
||||
// var move = request.Move;
|
||||
// if (move.PieceFromCaptured.HasValue)
|
||||
// session.Move(mapper.Map(move.PieceFromCaptured.Value), move.To);
|
||||
// else if (!string.IsNullOrWhiteSpace(move.From))
|
||||
// session.Move(move.From, move.To, move.IsPromotion);
|
||||
|
||||
// await gameboardRepository.CreateBoardState(session);
|
||||
// await communicationManager.BroadcastToPlayers(
|
||||
// new MoveResponse
|
||||
// {
|
||||
// SessionName = session.Name,
|
||||
// PlayerName = user.Id
|
||||
// },
|
||||
// session.Player1,
|
||||
// session.Player2);
|
||||
|
||||
// return Ok();
|
||||
// }
|
||||
// catch (InvalidOperationException ex)
|
||||
// {
|
||||
// return Conflict(ex.Message);
|
||||
// }
|
||||
//}
|
||||
|
||||
// TODO: Use JWT tokens for guests so they can authenticate and use API routes, too.
|
||||
//[Route("")]
|
||||
//public async Task<IActionResult> PostSession([FromBody] PostSession request)
|
||||
//{
|
||||
// var model = new Models.Session(request.Name, request.IsPrivate, request.Player1, request.Player2);
|
||||
// var success = await repository.CreateSession(model);
|
||||
// if (success)
|
||||
// {
|
||||
// var message = new ServiceModels.Socket.Messages.CreateGameResponse(ServiceModels.Types.SocketAction.CreateGame)
|
||||
// {
|
||||
// Game = model.ToServiceModel(),
|
||||
// PlayerName =
|
||||
// }
|
||||
// var task = request.IsPrivate
|
||||
// ? communicationManager.BroadcastToPlayers(response, userName)
|
||||
// : communicationManager.BroadcastToAll(response);
|
||||
// return new CreatedResult("", null);
|
||||
// }
|
||||
// return new ConflictResult();
|
||||
//}
|
||||
|
||||
|
||||
|
||||
//[HttpGet("{sessionName}")]
|
||||
//[AllowAnonymous]
|
||||
//public async Task<IActionResult> GetSession([FromRoute] string sessionName)
|
||||
//{
|
||||
// var user = await ReadUserOrThrow();
|
||||
// var session = await gameboardRepository.ReadSession(sessionName);
|
||||
// if (session == null)
|
||||
// {
|
||||
// return NotFound();
|
||||
// }
|
||||
|
||||
// var playerPerspective = session.Player2 == user.Id
|
||||
// ? WhichPlayer.Player2
|
||||
// : WhichPlayer.Player1;
|
||||
|
||||
// var response = new ReadSessionResponse
|
||||
// {
|
||||
// Session = new Session
|
||||
// {
|
||||
// BoardState = new BoardState
|
||||
// {
|
||||
// Board = mapper.Map(session.BoardState.State),
|
||||
// Player1Hand = session.BoardState.Player1Hand.Select(mapper.Map).ToList(),
|
||||
// Player2Hand = session.BoardState.Player2Hand.Select(mapper.Map).ToList(),
|
||||
// PlayerInCheck = mapper.Map(session.BoardState.InCheck)
|
||||
// },
|
||||
// SessionName = session.Name,
|
||||
// Player1 = session.Player1,
|
||||
// Player2 = session.Player2
|
||||
// }
|
||||
// };
|
||||
// return Ok(response);
|
||||
//}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<ReadAllSessionsResponse>> GetSessions()
|
||||
{
|
||||
var sessions = await this.queryRespository.ReadAllSessionsMetadata();
|
||||
|
||||
return Ok(new ReadAllSessionsResponse
|
||||
{
|
||||
PlayerHasJoinedSessions = Array.Empty<SessionMetadata>(),
|
||||
AllOtherSessions = sessions.ToList()
|
||||
});
|
||||
}
|
||||
|
||||
//[HttpPut("{sessionName}")]
|
||||
//public async Task<IActionResult> PutJoinSession([FromRoute] string sessionName)
|
||||
//{
|
||||
// var user = await ReadUserOrThrow();
|
||||
// var session = await gameboardRepository.ReadSessionMetaData(sessionName);
|
||||
// if (session == null)
|
||||
// {
|
||||
// return NotFound();
|
||||
// }
|
||||
// if (session.Player2 != null)
|
||||
// {
|
||||
// return this.Conflict("This session already has two seated players and is full.");
|
||||
// }
|
||||
|
||||
// session.SetPlayer2(user.Id);
|
||||
// await gameboardRepository.UpdateSession(session);
|
||||
|
||||
// var opponentName = user.Id == session.Player1
|
||||
// ? session.Player2!
|
||||
// : session.Player1;
|
||||
// await communicationManager.BroadcastToPlayers(new JoinSessionResponse
|
||||
// {
|
||||
// SessionName = session.Name,
|
||||
// PlayerName = user.Id
|
||||
// }, opponentName);
|
||||
// return Ok();
|
||||
//}
|
||||
|
||||
//[Authorize(Roles = "Admin")]
|
||||
//[HttpDelete("{sessionName}")]
|
||||
//public async Task<IActionResult> DeleteSession([FromRoute] string sessionName)
|
||||
//{
|
||||
// var user = await ReadUserOrThrow();
|
||||
// if (user.IsAdmin)
|
||||
// {
|
||||
// return Ok();
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// return Unauthorized();
|
||||
// }
|
||||
//}
|
||||
|
||||
//private async Task<Models.User> ReadUserOrThrow()
|
||||
//{
|
||||
// var user = await gameboardManager.ReadUser(User);
|
||||
// if (user == null)
|
||||
// {
|
||||
// throw new UnauthorizedAccessException("Unknown user claims.");
|
||||
// }
|
||||
// return user;
|
||||
//}
|
||||
}
|
||||
108
Shogi.Sockets/Controllers/UserController.cs
Normal file
108
Shogi.Sockets/Controllers/UserController.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shogi.Contracts.Api;
|
||||
using Shogi.Api.Extensions;
|
||||
using Shogi.Api.Managers;
|
||||
using Shogi.Api.Models;
|
||||
using Shogi.Api.Repositories;
|
||||
using System.Security.Claims;
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPut("GuestLogout")]
|
||||
public async Task<IActionResult> GuestLogout()
|
||||
{
|
||||
var signoutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
var userId = User?.GetGuestUserId();
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
connectionManager.Unsubscribe(userId);
|
||||
}
|
||||
|
||||
await signoutTask;
|
||||
return Ok();
|
||||
}
|
||||
|
||||
//[HttpGet("Token")]
|
||||
//public async Task<IActionResult> GetToken()
|
||||
//{
|
||||
// var user = await gameboardManager.ReadUser(User);
|
||||
// if (user == null)
|
||||
// {
|
||||
// await gameboardManager.CreateUser(User);
|
||||
// user = await gameboardManager.ReadUser(User);
|
||||
// }
|
||||
|
||||
// if (user == null)
|
||||
// {
|
||||
// return Unauthorized();
|
||||
// }
|
||||
|
||||
// var token = tokenCache.GenerateToken(user.Id);
|
||||
// return new JsonResult(new CreateTokenResponse(token));
|
||||
//}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("LoginAsGuest")]
|
||||
public async Task<IActionResult> GuestLogin()
|
||||
{
|
||||
var principal = await this.claimsTransformation.CreateClaimsFromGuestPrincipal(User);
|
||||
if (principal != null)
|
||||
{
|
||||
await HttpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
principal,
|
||||
authenticationProps
|
||||
);
|
||||
}
|
||||
return Ok();
|
||||
|
||||
|
||||
}
|
||||
[HttpGet("GuestToken")]
|
||||
public IActionResult GetGuestToken()
|
||||
{
|
||||
var id = User.GetGuestUserId();
|
||||
var displayName = User.DisplayName();
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
var token = tokenCache.GenerateToken(User.GetGuestUserId()!);
|
||||
return this.Ok(new CreateGuestTokenResponse(id, displayName, token));
|
||||
}
|
||||
|
||||
return this.Unauthorized();
|
||||
}
|
||||
}
|
||||
39
Shogi.Sockets/ExampleAnonymousSessionMiddleware.cs
Normal file
39
Shogi.Sockets/ExampleAnonymousSessionMiddleware.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Shogi.Sockets/Extensions/Extensions.cs
Normal file
30
Shogi.Sockets/Extensions/Extensions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Shogi.Api.Extensions;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
private static readonly string MsalUsernameClaim = "preferred_username";
|
||||
|
||||
public static string? GetGuestUserId(this ClaimsPrincipal self)
|
||||
{
|
||||
return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
|
||||
public static string? DisplayName(this ClaimsPrincipal self)
|
||||
{
|
||||
return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
|
||||
}
|
||||
|
||||
public static bool IsMicrosoft(this ClaimsPrincipal self)
|
||||
{
|
||||
return self.HasClaim(c => c.Type == MsalUsernameClaim);
|
||||
}
|
||||
|
||||
public static string? GetMicrosoftUserId(this ClaimsPrincipal self)
|
||||
{
|
||||
return self.Claims.FirstOrDefault(c => c.Type == MsalUsernameClaim)?.Value;
|
||||
}
|
||||
|
||||
public static string? GetShogiUserId(this ClaimsPrincipal self) => self.IsMicrosoft() ? self.GetMicrosoftUserId() : self.GetGuestUserId();
|
||||
}
|
||||
50
Shogi.Sockets/Extensions/LogMiddleware.cs
Normal file
50
Shogi.Sockets/Extensions/LogMiddleware.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Shogi.Sockets/Extensions/WebSocketExtensions.cs
Normal file
21
Shogi.Sockets/Extensions/WebSocketExtensions.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Shogi.Api.Extensions
|
||||
{
|
||||
public static class WebSocketExtensions
|
||||
{
|
||||
public static async Task SendTextAsync(this WebSocket self, string message)
|
||||
{
|
||||
await self.SendAsync(Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
|
||||
public static async Task<string> ReceiveTextAsync(this WebSocket self)
|
||||
{
|
||||
var buffer = new ArraySegment<byte>(new byte[2048]);
|
||||
var receive = await self.ReceiveAsync(buffer, CancellationToken.None);
|
||||
return Encoding.UTF8.GetString(buffer.Slice(0, receive.Count));
|
||||
// TODO: Make this robust to multi-frame messages.
|
||||
}
|
||||
}
|
||||
}
|
||||
86
Shogi.Sockets/Managers/ModelMapper.cs
Normal file
86
Shogi.Sockets/Managers/ModelMapper.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using Shogi.Contracts.Types;
|
||||
using DomainWhichPiece = Shogi.Domain.WhichPiece;
|
||||
using DomainWhichPlayer = Shogi.Domain.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);
|
||||
}
|
||||
}
|
||||
89
Shogi.Sockets/Managers/SocketConnectionManager.cs
Normal file
89
Shogi.Sockets/Managers/SocketConnectionManager.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
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(ISocketResponse response);
|
||||
void Subscribe(WebSocket socket, string playerName);
|
||||
void Unsubscribe(string playerName);
|
||||
Task BroadcastToPlayers(ISocketResponse 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(ISocketResponse 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(ISocketResponse 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);
|
||||
}
|
||||
54
Shogi.Sockets/Managers/SocketTokenCache.cs
Normal file
54
Shogi.Sockets/Managers/SocketTokenCache.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Shogi.Sockets/Models/User.cs
Normal file
42
Shogi.Sockets/Models/User.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
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"
|
||||
});
|
||||
public static readonly ReadOnlyCollection<string> Subjects = new(new[] {
|
||||
"Hippo", "Basil", "Mouse", "Walnut", "Prince", "Lima Bean", "Coala", "Potato", "Penguin"
|
||||
});
|
||||
public static User CreateMsalUser(string id) => new(id, id, 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 bool IsGuest => LoginPlatform == WhichLoginPlatform.Guest;
|
||||
|
||||
public bool IsAdmin => LoginPlatform == WhichLoginPlatform.Microsoft && Id == "Hauth@live.com";
|
||||
|
||||
public User(string id, string displayName, WhichLoginPlatform platform)
|
||||
{
|
||||
Id = id;
|
||||
DisplayName = displayName;
|
||||
LoginPlatform = platform;
|
||||
}
|
||||
}
|
||||
9
Shogi.Sockets/Models/WhichLoginPlatform.cs
Normal file
9
Shogi.Sockets/Models/WhichLoginPlatform.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Shogi.Api.Models
|
||||
{
|
||||
public enum WhichLoginPlatform
|
||||
{
|
||||
Unknown,
|
||||
Microsoft,
|
||||
Guest
|
||||
}
|
||||
}
|
||||
263
Shogi.Sockets/Program.cs
Normal file
263
Shogi.Sockets/Program.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.HttpLogging;
|
||||
using Microsoft.Identity.Web;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Shogi.Api.Managers;
|
||||
using Shogi.Api.Repositories;
|
||||
using Shogi.Api.Services;
|
||||
using System.Text;
|
||||
|
||||
namespace Shogi.Api
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
|
||||
Console.WriteLine(string.Join("\n", allowedOrigins));
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy
|
||||
.WithOrigins(allowedOrigins)
|
||||
.SetIsOriginAllowedToAllowWildcardSubdomains()
|
||||
.WithExposedHeaders("Set-Cookie")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
ConfigureAuthentication(builder);
|
||||
ConfigureControllersWithNewtonsoft(builder);
|
||||
ConfigureSwagger(builder);
|
||||
ConfigureDependencyInjection(builder);
|
||||
ConfigureLogging(builder);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseWhen(
|
||||
// Log anything that isn't related to swagger.
|
||||
context => ShouldLog(context),
|
||||
appBuilder => appBuilder.UseHttpLogging());
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
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;
|
||||
});
|
||||
app.UseHttpsRedirection(); // Apache handles HTTPS in production.
|
||||
}
|
||||
|
||||
UseCorsAndWebSockets(app, allowedOrigins);
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.Map("/", () => "OK");
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool ShouldLog(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);
|
||||
}
|
||||
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 ConfigureControllersWithNewtonsoft(WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services
|
||||
.AddControllers()
|
||||
//.AddJsonOptions(options =>
|
||||
//{
|
||||
// options.AllowInputFormatterExceptionMessages = true;
|
||||
// options.JsonSerializerOptions.WriteIndented = true;
|
||||
//});
|
||||
.AddNewtonsoftJson(options =>
|
||||
{
|
||||
options.SerializerSettings.Formatting = Formatting.Indented;
|
||||
options.SerializerSettings.ContractResolver = new DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = false }
|
||||
};
|
||||
options.SerializerSettings.Converters = new[] { new StringEnumConverter() };
|
||||
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
|
||||
});
|
||||
|
||||
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
ContractResolver = new DefaultContractResolver
|
||||
{
|
||||
NamingStrategy = new CamelCaseNamingStrategy
|
||||
{
|
||||
ProcessDictionaryKeys = false
|
||||
}
|
||||
},
|
||||
Converters = new[] { new StringEnumConverter() },
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
};
|
||||
}
|
||||
|
||||
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.AddHttpClient("couchdb", c =>
|
||||
{
|
||||
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:admin"));
|
||||
c.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
c.DefaultRequestHeaders.Add("Authorization", $"Basic {base64}");
|
||||
|
||||
var baseUrl = $"{builder.Configuration["AppSettings:CouchDB:Url"]}/{builder.Configuration["AppSettings:CouchDB:Database"]}/";
|
||||
c.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
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
|
||||
{
|
||||
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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
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>()
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<DeleteExistingFiles>true</DeleteExistingFiles>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<PublishProvider>FileSystem</PublishProvider>
|
||||
<PublishUrl>bin\Release\net6.0\publish\</PublishUrl>
|
||||
<WebPublishMethod>FileSystem</WebPublishMethod>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<ProjectGuid>4ff35f9d-e525-46cf-a8a6-a147fe50ad68</ProjectGuid>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"resourceGroupName": {
|
||||
"type": "string",
|
||||
"defaultValue": "DefaultResourceGroup-CUS",
|
||||
"metadata": {
|
||||
"_parameterType": "resourceGroup",
|
||||
"description": "Name of the resource group for the resource. It is recommended to put resources under same resource group for better tracking."
|
||||
}
|
||||
},
|
||||
"resourceGroupLocation": {
|
||||
"type": "string",
|
||||
"defaultValue": "centralus",
|
||||
"metadata": {
|
||||
"_parameterType": "location",
|
||||
"description": "Location of the resource group. Resource groups could have different location than resources."
|
||||
}
|
||||
},
|
||||
"resourceLocation": {
|
||||
"type": "string",
|
||||
"defaultValue": "[parameters('resourceGroupLocation')]",
|
||||
"metadata": {
|
||||
"_parameterType": "location",
|
||||
"description": "Location of the resource. By default use resource group's location, unless the resource provider is not supported there."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "Microsoft.Resources/resourceGroups",
|
||||
"name": "[parameters('resourceGroupName')]",
|
||||
"location": "[parameters('resourceGroupLocation')]",
|
||||
"apiVersion": "2019-10-01"
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Resources/deployments",
|
||||
"name": "[concat(parameters('resourceGroupName'), 'Deployment', uniqueString(concat('GameboardShogiUISocketsv', subscription().subscriptionId)))]",
|
||||
"resourceGroup": "[parameters('resourceGroupName')]",
|
||||
"apiVersion": "2019-10-01",
|
||||
"dependsOn": [
|
||||
"[parameters('resourceGroupName')]"
|
||||
],
|
||||
"properties": {
|
||||
"mode": "Incremental",
|
||||
"template": {
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"resources": [
|
||||
{
|
||||
"name": "GameboardShogiUISocketsv",
|
||||
"type": "Microsoft.KeyVault/vaults",
|
||||
"location": "[parameters('resourceLocation')]",
|
||||
"properties": {
|
||||
"sku": {
|
||||
"family": "A",
|
||||
"name": "Standard"
|
||||
},
|
||||
"tenantId": "d6019544-c403-415c-8e96-50009635b6aa",
|
||||
"accessPolicies": [],
|
||||
"enabledForDeployment": true,
|
||||
"enabledForDiskEncryption": true,
|
||||
"enabledForTemplateDeployment": true
|
||||
},
|
||||
"apiVersion": "2016-10-01"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"_dependencyType": "secrets.keyVault"
|
||||
}
|
||||
}
|
||||
24
Shogi.Sockets/Properties/launchSettings.json
Normal file
24
Shogi.Sockets/Properties/launchSettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"profiles": {
|
||||
"Kestrel": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"VaultUri": "https://gameboardshogiuisocketsv.vault.azure.net/",
|
||||
"AZURE_USERNAME": "Hauth@live.com"
|
||||
},
|
||||
"applicationUrl": "https://localhost:5001;http://localhost:5000"
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:50728/",
|
||||
"sslPort": 44315
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Shogi.Sockets/Properties/serviceDependencies.json
Normal file
13
Shogi.Sockets/Properties/serviceDependencies.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"identityapp1": {
|
||||
"type": "identityapp",
|
||||
"dynamicId": null
|
||||
},
|
||||
"secrets1": {
|
||||
"type": "secrets",
|
||||
"connectionId": "VaultUri",
|
||||
"dynamicId": null
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Shogi.Sockets/Properties/serviceDependencies.local.json
Normal file
15
Shogi.Sockets/Properties/serviceDependencies.local.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"identityapp1": {
|
||||
"type": "identityapp.default",
|
||||
"dynamicId": null
|
||||
},
|
||||
"secrets1": {
|
||||
"secretStore": null,
|
||||
"resourceId": "/subscriptions/[parameters('subscriptionId')]/resourceGroups/[parameters('resourceGroupName')]/providers/Microsoft.KeyVault/vaults/GameboardShogiUISocketsv",
|
||||
"type": "secrets.keyVault",
|
||||
"connectionId": "VaultUri",
|
||||
"dynamicId": null
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Shogi.Sockets/Readme.md
Normal file
4
Shogi.Sockets/Readme.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Shogi.Sockets
|
||||
|
||||
# Forgetmenots
|
||||
Don't forget to run `dotnet user-secrets init` within the AAT project.
|
||||
49
Shogi.Sockets/Repositories/CouchModels/BoardStateDocument.cs
Normal file
49
Shogi.Sockets/Repositories/CouchModels/BoardStateDocument.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Shogi.Domain;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class BoardStateDocument : CouchDocument
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary where the key is a board-notation position, like D3.
|
||||
/// </summary>
|
||||
public Dictionary<string, Piece?> Board { get; set; }
|
||||
|
||||
public Piece[] Player1Hand { get; set; }
|
||||
|
||||
public Piece[] Player2Hand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Move is null for first BoardState of a session - before anybody has made moves.
|
||||
/// </summary>
|
||||
public Move? Move { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default constructor and setters are for deserialization.
|
||||
/// </summary>
|
||||
public BoardStateDocument() : base(WhichDocumentType.BoardState)
|
||||
{
|
||||
Name = string.Empty;
|
||||
Board = new Dictionary<string, Piece?>(81, StringComparer.OrdinalIgnoreCase);
|
||||
Player1Hand = Array.Empty<Piece>();
|
||||
Player2Hand = Array.Empty<Piece>();
|
||||
}
|
||||
|
||||
public BoardStateDocument(string sessionName, Session shogi)
|
||||
: base($"{sessionName}-{DateTime.Now:O}", WhichDocumentType.BoardState)
|
||||
{
|
||||
static Piece MapPiece(Domain.ValueObjects.Piece piece)
|
||||
{
|
||||
return new Piece { IsPromoted = piece.IsPromoted, Owner = piece.Owner, WhichPiece = piece.WhichPiece };
|
||||
}
|
||||
|
||||
Name = sessionName;
|
||||
Board = shogi.BoardState.State.ToDictionary(kvp => kvp.Key, kvp => kvp.Value == null ? null : MapPiece(kvp.Value));
|
||||
|
||||
Player1Hand = shogi.BoardState.Player1Hand.Select(piece => MapPiece(piece)).ToArray();
|
||||
Player2Hand = shogi.BoardState.Player2Hand.Select(piece => MapPiece(piece)).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Shogi.Sockets/Repositories/CouchModels/CouchCreatedResult.cs
Normal file
15
Shogi.Sockets/Repositories/CouchModels/CouchCreatedResult.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class CouchCreateResult
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool Ok { get; set; }
|
||||
public string Rev { get; set; }
|
||||
|
||||
public CouchCreateResult()
|
||||
{
|
||||
Id = string.Empty;
|
||||
Rev = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Shogi.Sockets/Repositories/CouchModels/CouchDocument.cs
Normal file
26
Shogi.Sockets/Repositories/CouchModels/CouchDocument.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public abstract class CouchDocument
|
||||
{
|
||||
[JsonProperty("_id")] public string Id { get; set; }
|
||||
[JsonProperty("_rev")] public string? RevisionId { get; set; }
|
||||
public WhichDocumentType DocumentType { get; }
|
||||
public DateTimeOffset CreatedDate { get; set; }
|
||||
|
||||
public CouchDocument(WhichDocumentType documentType)
|
||||
: this(string.Empty, documentType, DateTimeOffset.UtcNow) { }
|
||||
|
||||
public CouchDocument(string id, WhichDocumentType documentType)
|
||||
: this(id, documentType, DateTimeOffset.UtcNow) { }
|
||||
|
||||
public CouchDocument(string id, WhichDocumentType documentType, DateTimeOffset createdDate)
|
||||
{
|
||||
Id = id;
|
||||
DocumentType = documentType;
|
||||
CreatedDate = createdDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Shogi.Sockets/Repositories/CouchModels/CouchFindResult.cs
Normal file
16
Shogi.Sockets/Repositories/CouchModels/CouchFindResult.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
internal class CouchFindResult<T>
|
||||
{
|
||||
public T[] docs;
|
||||
public string warning;
|
||||
|
||||
public CouchFindResult()
|
||||
{
|
||||
docs = Array.Empty<T>();
|
||||
warning = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Shogi.Sockets/Repositories/CouchModels/CouchViewResult.cs
Normal file
28
Shogi.Sockets/Repositories/CouchModels/CouchViewResult.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class CouchViewResult<T> where T : class
|
||||
{
|
||||
public int total_rows;
|
||||
public int offset;
|
||||
public CouchViewResultRow<T>[] rows;
|
||||
|
||||
public CouchViewResult()
|
||||
{
|
||||
rows = Array.Empty<CouchViewResultRow<T>>();
|
||||
}
|
||||
}
|
||||
|
||||
public class CouchViewResultRow<T>
|
||||
{
|
||||
public string id;
|
||||
public T doc;
|
||||
|
||||
public CouchViewResultRow()
|
||||
{
|
||||
id = string.Empty;
|
||||
doc = default!;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
Shogi.Sockets/Repositories/CouchModels/Move.cs
Normal file
32
Shogi.Sockets/Repositories/CouchModels/Move.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Shogi.Domain;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class Move
|
||||
{
|
||||
/// <summary>
|
||||
/// A board coordinate, like A3 or G6. When null, look for PieceFromHand to exist.
|
||||
/// </summary>
|
||||
public string? From { get; set; }
|
||||
|
||||
public bool IsPromotion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The piece placed from the player's hand.
|
||||
/// </summary>
|
||||
public WhichPiece? PieceFromHand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A board coordinate, like A3 or G6.
|
||||
/// </summary>
|
||||
public string To { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default constructor and setters are for deserialization.
|
||||
/// </summary>
|
||||
public Move()
|
||||
{
|
||||
To = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Shogi.Sockets/Repositories/CouchModels/Piece.cs
Normal file
11
Shogi.Sockets/Repositories/CouchModels/Piece.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Shogi.Domain;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class Piece
|
||||
{
|
||||
public bool IsPromoted { get; set; }
|
||||
public WhichPlayer Owner { get; set; }
|
||||
public WhichPiece WhichPiece { get; set; }
|
||||
}
|
||||
}
|
||||
28
Shogi.Sockets/Repositories/CouchModels/SessionDocument.cs
Normal file
28
Shogi.Sockets/Repositories/CouchModels/SessionDocument.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Shogi.Contracts.Types;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class SessionDocument : CouchDocument
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Player1Id { get; set; }
|
||||
public string? Player2Id { get; set; }
|
||||
public bool IsPrivate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default constructor and setters are for deserialization.
|
||||
/// </summary>
|
||||
public SessionDocument() : base(WhichDocumentType.Session)
|
||||
{
|
||||
Name = string.Empty;
|
||||
Player1Id = string.Empty;
|
||||
Player2Id = string.Empty;
|
||||
}
|
||||
|
||||
public SessionDocument(SessionMetadata sessionMetaData)
|
||||
: base(sessionMetaData.Name, WhichDocumentType.Session)
|
||||
{
|
||||
Name = sessionMetaData.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Shogi.Sockets/Repositories/CouchModels/UserDocument.cs
Normal file
27
Shogi.Sockets/Repositories/CouchModels/UserDocument.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Shogi.Api.Models;
|
||||
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public class UserDocument : CouchDocument
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public WhichLoginPlatform Platform { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Constructor for JSON deserializing.
|
||||
/// </summary>
|
||||
public UserDocument() : base(WhichDocumentType.User)
|
||||
{
|
||||
DisplayName = string.Empty;
|
||||
}
|
||||
|
||||
public UserDocument(
|
||||
string id,
|
||||
string displayName,
|
||||
WhichLoginPlatform platform) : base(id, WhichDocumentType.User)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
Platform = platform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Shogi.Api.Repositories.CouchModels
|
||||
{
|
||||
public enum WhichDocumentType
|
||||
{
|
||||
User,
|
||||
Session,
|
||||
BoardState
|
||||
}
|
||||
}
|
||||
232
Shogi.Sockets/Repositories/GameboardRepository.cs
Normal file
232
Shogi.Sockets/Repositories/GameboardRepository.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Shogi.Contracts.Types;
|
||||
using Shogi.Api.Repositories.CouchModels;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using Session = Shogi.Domain.Session;
|
||||
|
||||
namespace Shogi.Api.Repositories
|
||||
{
|
||||
public interface IGameboardRepository
|
||||
{
|
||||
Task CreateBoardState(Session session);
|
||||
Task CreateUser(Models.User user);
|
||||
Task<Collection<SessionMetadata>> ReadSessionMetadatas();
|
||||
Task<Session?> ReadSession(string name);
|
||||
Task UpdateSession(SessionMetadata session);
|
||||
Task<SessionMetadata?> ReadSessionMetaData(string name);
|
||||
Task<Models.User?> ReadUser(string userName);
|
||||
}
|
||||
|
||||
public class GameboardRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns session, board state, and user documents, grouped by session.
|
||||
/// </summary>
|
||||
private static readonly string View_SessionWithBoardState = "_design/session/_view/session-with-boardstate";
|
||||
/// <summary>
|
||||
/// Returns session and user documents, grouped by session.
|
||||
/// </summary>
|
||||
private static readonly string View_SessionMetadata = "_design/session/_view/session-metadata";
|
||||
private static readonly string View_User = "_design/user/_view/user";
|
||||
private const string ApplicationJson = "application/json";
|
||||
private readonly HttpClient client;
|
||||
private readonly ILogger<GameboardRepository> logger;
|
||||
|
||||
public GameboardRepository(IHttpClientFactory clientFactory, ILogger<GameboardRepository> logger)
|
||||
{
|
||||
client = clientFactory.CreateClient("couchdb");
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
//public async Task<Collection<SessionMetadata>> ReadSessionMetadatas()
|
||||
//{
|
||||
// var queryParams = new QueryBuilder { { "include_docs", "true" } }.ToQueryString();
|
||||
// var response = await client.GetAsync($"{View_SessionMetadata}{queryParams}");
|
||||
// var responseContent = await response.Content.ReadAsStringAsync();
|
||||
// var result = JsonConvert.DeserializeObject<CouchViewResult<JObject>>(responseContent);
|
||||
// if (result != null)
|
||||
// {
|
||||
// var groupedBySession = result.rows.GroupBy(row => row.id);
|
||||
// var sessions = new List<SessionMetadata>(result.total_rows / 3);
|
||||
// foreach (var group in groupedBySession)
|
||||
// {
|
||||
// /**
|
||||
// * A group contains 3 elements.
|
||||
// * 1) The session metadata.
|
||||
// * 2) User document of Player1.
|
||||
// * 3) User document of Player2.
|
||||
// */
|
||||
// var session = group.FirstOrDefault()?.doc.ToObject<SessionDocument>();
|
||||
// var player1 = group.Skip(1).FirstOrDefault()?.doc.ToObject<UserDocument>();
|
||||
// var player2Doc = group.Skip(2).FirstOrDefault()?.doc;
|
||||
// if (session != null && player1 != null && player2Doc != null)
|
||||
// {
|
||||
// var player2 = IsUserDocument(player2Doc)
|
||||
// ? new Models.User(player2Doc.ToObject<UserDocument>()!)
|
||||
// : null;
|
||||
// //sessions.Add(new SessionMetadata(session.Name, session.IsPrivate, player1.Id, player2?.Id));
|
||||
// }
|
||||
// }
|
||||
// return new Collection<SessionMetadata>(sessions);
|
||||
// }
|
||||
// return new Collection<SessionMetadata>(Array.Empty<SessionMetadata>());
|
||||
//}
|
||||
|
||||
private static bool IsUserDocument(JObject player2Doc)
|
||||
{
|
||||
return player2Doc?.SelectToken(nameof(CouchDocument.DocumentType))?.Value<WhichDocumentType>() == WhichDocumentType.User;
|
||||
}
|
||||
|
||||
//public async Task<Session?> ReadSession(string name)
|
||||
//{
|
||||
// static Domain.ValueObjects.Piece? MapPiece(Piece? piece)
|
||||
// {
|
||||
// return piece == null
|
||||
// ? null
|
||||
// : Domain.ValueObjects.Piece.Create(piece.WhichPiece, piece.Owner, piece.IsPromoted);
|
||||
// }
|
||||
|
||||
// var queryParams = new QueryBuilder
|
||||
// {
|
||||
// { "include_docs", "true" },
|
||||
// { "startkey", JsonConvert.SerializeObject(new [] {name}) },
|
||||
// { "endkey", JsonConvert.SerializeObject(new object [] {name, int.MaxValue}) }
|
||||
// }.ToQueryString();
|
||||
// var query = $"{View_SessionWithBoardState}{queryParams}";
|
||||
// logger.LogInformation("ReadSession() query: {query}", query);
|
||||
// var response = await client.GetAsync(query);
|
||||
// var responseContent = await response.Content.ReadAsStringAsync();
|
||||
// var result = JsonConvert.DeserializeObject<CouchViewResult<JObject>>(responseContent);
|
||||
// if (result != null && result.rows.Length > 2)
|
||||
// {
|
||||
// var group = result.rows;
|
||||
// /**
|
||||
// * A group contains multiple elements.
|
||||
// * 0) The session metadata.
|
||||
// * 1) User documents of Player1.
|
||||
// * 2) User documents of Player1.
|
||||
// * 2.a) If the Player2 document doesn't exist, CouchDB will return the SessionDocument instead :(
|
||||
// * Everything Else) Snapshots of the boardstate after every player move.
|
||||
// */
|
||||
// var session = group[0].doc.ToObject<SessionDocument>();
|
||||
// var player1 = group[1].doc.ToObject<UserDocument>();
|
||||
// var player2Doc = group[2].doc;
|
||||
// var boardState = group.Last().doc.ToObject<BoardStateDocument>();
|
||||
|
||||
// if (session != null && player1 != null && boardState != null)
|
||||
// {
|
||||
// var player2 = IsUserDocument(player2Doc)
|
||||
// ? new Models.User(player2Doc.ToObject<UserDocument>()!)
|
||||
// : null;
|
||||
// var metaData = new SessionMetadata(session.Name, session.IsPrivate, player1.Id, player2?.Id);
|
||||
// var shogiBoardState = new BoardState(boardState.Board.ToDictionary(kvp => kvp.Key, kvp => MapPiece(kvp.Value)));
|
||||
// //return new Session(shogiBoardState, metaData);
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
//}
|
||||
|
||||
//public async Task<SessionMetadata?> ReadSessionMetaData(string name)
|
||||
//{
|
||||
// var queryParams = new QueryBuilder
|
||||
// {
|
||||
// { "include_docs", "true" },
|
||||
// { "startkey", JsonConvert.SerializeObject(new [] {name}) },
|
||||
// { "endkey", JsonConvert.SerializeObject(new object [] {name, int.MaxValue}) }
|
||||
// }.ToQueryString();
|
||||
// var response = await client.GetAsync($"{View_SessionMetadata}{queryParams}");
|
||||
// var responseContent = await response.Content.ReadAsStringAsync();
|
||||
// var result = JsonConvert.DeserializeObject<CouchViewResult<JObject>>(responseContent);
|
||||
// if (result != null && result.rows.Length > 2)
|
||||
// {
|
||||
// var group = result.rows;
|
||||
// /**
|
||||
// * A group contains 3 elements.
|
||||
// * 1) The session metadata.
|
||||
// * 2) User document of Player1.
|
||||
// * 3) User document of Player2.
|
||||
// */
|
||||
// var session = group[0].doc.ToObject<SessionDocument>();
|
||||
// var player1 = group[1].doc.ToObject<UserDocument>();
|
||||
// var player2Doc = group[2].doc;
|
||||
// if (session != null && player1 != null)
|
||||
// {
|
||||
// var player2 = IsUserDocument(player2Doc)
|
||||
// ? new Models.User(player2Doc.ToObject<UserDocument>()!)
|
||||
// : null;
|
||||
// return new SessionMetadata(session.Name, session.IsPrivate, player1.Id, player2?.Id);
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
//}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a snapshot of board state and the most recent move.
|
||||
/// </summary>
|
||||
public async Task CreateBoardState(Session session)
|
||||
{
|
||||
var boardStateDocument = new BoardStateDocument(session.Name, session);
|
||||
var content = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson);
|
||||
var response = await client.PostAsync(string.Empty, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
//public async Task<bool> CreateSession(SessionMetadata session)
|
||||
//{
|
||||
// var sessionDocument = new SessionDocument(session);
|
||||
// var sessionContent = new StringContent(JsonConvert.SerializeObject(sessionDocument), Encoding.UTF8, ApplicationJson);
|
||||
// var postSessionDocumentTask = client.PostAsync(string.Empty, sessionContent);
|
||||
|
||||
// var boardStateDocument = new BoardStateDocument(session.Name, new Session());
|
||||
// var boardStateContent = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson);
|
||||
|
||||
// if ((await postSessionDocumentTask).IsSuccessStatusCode)
|
||||
// {
|
||||
// var response = await client.PostAsync(string.Empty, boardStateContent);
|
||||
// return response.IsSuccessStatusCode;
|
||||
// }
|
||||
|
||||
// return false;
|
||||
//}
|
||||
|
||||
//public async Task UpdateSession(SessionMetadata session)
|
||||
//{
|
||||
// // GET existing session to get revisionId.
|
||||
// var readResponse = await client.GetAsync(session.Name);
|
||||
// readResponse.EnsureSuccessStatusCode();
|
||||
// var sessionDocument = JsonConvert.DeserializeObject<SessionDocument>(await readResponse.Content.ReadAsStringAsync());
|
||||
|
||||
// // PUT the document with the revisionId.
|
||||
// var couchModel = new SessionDocument(session)
|
||||
// {
|
||||
// RevisionId = sessionDocument?.RevisionId
|
||||
// };
|
||||
// var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson);
|
||||
// var response = await client.PutAsync(couchModel.Id, content);
|
||||
// response.EnsureSuccessStatusCode();
|
||||
//}
|
||||
|
||||
//public async Task<bool> PutJoinPublicSession(PutJoinPublicSession request)
|
||||
//{
|
||||
// var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, MediaType);
|
||||
// var response = await client.PutAsync(JoinSessionRoute, content);
|
||||
// var json = await response.Content.ReadAsStringAsync();
|
||||
// return JsonConvert.DeserializeObject<PutJoinPublicSessionResponse>(json).JoinSucceeded;
|
||||
//}
|
||||
|
||||
//public async Task<string> PostJoinPrivateSession(PostJoinPrivateSession request)
|
||||
//{
|
||||
// var content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, MediaType);
|
||||
// var response = await client.PostAsync(JoinSessionRoute, content);
|
||||
// var json = await response.Content.ReadAsStringAsync();
|
||||
// var deserialized = JsonConvert.DeserializeObject<PostJoinPrivateSessionResponse>(json);
|
||||
// if (deserialized.JoinSucceeded)
|
||||
// {
|
||||
// return deserialized.SessionName;
|
||||
// }
|
||||
// return null;
|
||||
//}
|
||||
}
|
||||
}
|
||||
28
Shogi.Sockets/Repositories/QueryRepository.cs
Normal file
28
Shogi.Sockets/Repositories/QueryRepository.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Dapper;
|
||||
using Shogi.Contracts.Types;
|
||||
using System.Data.SqlClient;
|
||||
|
||||
namespace Shogi.Api.Repositories;
|
||||
|
||||
public class QueryRepository : IQueryRespository
|
||||
{
|
||||
private readonly string connectionString;
|
||||
|
||||
public QueryRepository(IConfiguration configuration)
|
||||
{
|
||||
connectionString = configuration.GetConnectionString("ShogiDatabase");
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SessionMetadata>> ReadAllSessionsMetadata()
|
||||
{
|
||||
using var connection = new SqlConnection(connectionString);
|
||||
return await connection.QueryAsync<SessionMetadata>(
|
||||
"session.ReadAllSessionsMetadata",
|
||||
commandType: System.Data.CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IQueryRespository
|
||||
{
|
||||
Task<IEnumerable<SessionMetadata>> ReadAllSessionsMetadata();
|
||||
}
|
||||
37
Shogi.Sockets/Repositories/SessionRepository.cs
Normal file
37
Shogi.Sockets/Repositories/SessionRepository.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Dapper;
|
||||
using Shogi.Domain;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Shogi.Api.Repositories;
|
||||
|
||||
public class SessionRepository : ISessionRepository
|
||||
{
|
||||
private readonly string connectionString;
|
||||
|
||||
public SessionRepository(IConfiguration configuration)
|
||||
{
|
||||
connectionString = configuration.GetConnectionString("ShogiDatabase");
|
||||
}
|
||||
|
||||
public async Task CreateSession(Session session)
|
||||
{
|
||||
var initialBoardState = JsonSerializer.Serialize(session.BoardState);
|
||||
using var connection = new SqlConnection(connectionString);
|
||||
await connection.ExecuteAsync(
|
||||
"session.CreateSession",
|
||||
new
|
||||
{
|
||||
SessionName = session.Name,
|
||||
Player1Name = session.Player1,
|
||||
InitialBoardStateDocument = initialBoardState
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ISessionRepository
|
||||
{
|
||||
Task CreateSession(Session session);
|
||||
}
|
||||
47
Shogi.Sockets/Repositories/UserRepository.cs
Normal file
47
Shogi.Sockets/Repositories/UserRepository.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
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)
|
||||
{
|
||||
connectionString = configuration.GetConnectionString("ShogiDatabase");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
100
Shogi.Sockets/Services/SocketService.cs
Normal file
100
Shogi.Sockets/Services/SocketService.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using FluentValidation;
|
||||
using Newtonsoft.Json;
|
||||
using Shogi.Contracts.Socket;
|
||||
using Shogi.Contracts.Types;
|
||||
using Shogi.Api.Extensions;
|
||||
using Shogi.Api.Managers;
|
||||
using Shogi.Api.Repositories;
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
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]);
|
||||
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);
|
||||
while (socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
var message = await socket.ReceiveTextAsync();
|
||||
if (string.IsNullOrWhiteSpace(message)) continue;
|
||||
logger.LogInformation("Request \n{0}\n", message);
|
||||
var request = JsonConvert.DeserializeObject<ISocketRequest>(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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateRequestAndReplyIfInvalid<TRequest>(WebSocket socket, IValidator<TRequest> validator, TRequest request)
|
||||
{
|
||||
var results = validator.Validate(request);
|
||||
if (!results.IsValid)
|
||||
{
|
||||
var errors = string.Join('\n', results.Errors.Select(_ => _.ErrorMessage));
|
||||
await socket.SendTextAsync(errors);
|
||||
}
|
||||
return results.IsValid;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Shogi.Sockets/Shogi.Api.csproj
Normal file
35
Shogi.Sockets/Shogi.Api.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>5</AnalysisLevel>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>False</GenerateDocumentationFile>
|
||||
<SignAssembly>False</SignAssembly>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>973a1f5f-ef25-4f1c-a24d-b0fc7d016ab8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.2.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.6.1" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="FluentValidation" Version="11.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.8" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="1.25.2" />
|
||||
<PackageReference Include="Microsoft.Identity.Web.UI" Version="1.25.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||
<PackageReference Include="System.Data.SqlClient" Version="4.8.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Shogi.Contracts\Shogi.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Shogi.Domain\Shogi.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
89
Shogi.Sockets/ShogiUserClaimsTransformer.cs
Normal file
89
Shogi.Sockets/ShogiUserClaimsTransformer.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Identity.Web;
|
||||
using Shogi.Api.Extensions;
|
||||
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 = principal.IsMicrosoft()
|
||||
? await CreateClaimsFromMicrosoftPrincipal(principal)
|
||||
: await CreateClaimsFromGuestPrincipal(principal);
|
||||
|
||||
return newPrincipal;
|
||||
}
|
||||
|
||||
public async Task<ClaimsPrincipal> CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal)
|
||||
{
|
||||
var id = principal.GetGuestUserId();
|
||||
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 = principal.GetMsalAccountId();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
throw new UnauthorizedAccessException("Found MSAL claims but no preferred_username.");
|
||||
}
|
||||
|
||||
var user = await this.userRepository.ReadUser(id);
|
||||
if (user == null)
|
||||
{
|
||||
user = User.CreateMsalUser(id);
|
||||
await this.userRepository.CreateUser(user);
|
||||
}
|
||||
return new ClaimsPrincipal(CreateClaimsIdentity(user));
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
9
Shogi.Sockets/appsettings.Development.json
Normal file
9
Shogi.Sockets/appsettings.Development.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Shogi.Sockets/appsettings.json
Normal file
34
Shogi.Sockets/appsettings.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"AppSettings": {
|
||||
"CouchDB": {
|
||||
"Database": "shogi-dev",
|
||||
"Url": "http://192.168.1.177:5984"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Sockets"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning",
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Error",
|
||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
|
||||
"System.Net.Http.HttpClient": "Error"
|
||||
}
|
||||
},
|
||||
"AzureAd": {
|
||||
"Instance": "https://login.microsoftonline.com/",
|
||||
"TenantId": "common",
|
||||
"ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff",
|
||||
"SwaggerUIClientId": "26bf69a4-2af8-4711-bf5b-79f75e20b082"
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:3000",
|
||||
"https://localhost:3000",
|
||||
"https://api.lucaserver.space",
|
||||
"https://lucaserver.space"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user