checkpoint

This commit is contained in:
2021-11-10 18:46:29 -06:00
parent 2a3b7b32b4
commit 20f44c8b90
26 changed files with 519 additions and 407 deletions

View File

@@ -2,18 +2,16 @@
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{ {
public class GetGuestToken
{
}
public class GetGuestTokenResponse public class GetGuestTokenResponse
{ {
public string PlayerName { get; } public string UserId { get; }
public string DisplayName { get; }
public Guid OneTimeToken { get; } public Guid OneTimeToken { get; }
public GetGuestTokenResponse(string playerName, Guid token) public GetGuestTokenResponse(string id, string displayName, Guid token)
{ {
PlayerName = playerName; UserId = id;
DisplayName = displayName;
OneTimeToken = token; OneTimeToken = token;
} }
} }

View File

@@ -1,13 +1,9 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{ {
public class GetGameResponse public class GetSessionResponse
{ {
public Game Game { get; set; } public Game Game { get; set; }
public WhichPlayer PlayerPerspective { get; set; } public WhichPlayer PlayerPerspective { get; set; }

View File

@@ -0,0 +1,11 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System.Collections.ObjectModel;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class GetSessionsResponse
{
public Collection<Game> PlayerHasJoinedSessions { get; set; }
public Collection<Game> AllOtherSessions { get; set; }
}
}

View File

@@ -17,14 +17,15 @@ namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket
public class JoinGameResponse : IResponse public class JoinGameResponse : IResponse
{ {
public string Action { get; protected set; } public string Action { get; protected set; }
public string Error { get; set; }
public string GameName { get; set; } public string GameName { get; set; }
/// <summary>
/// The player who joined the game.
/// </summary>
public string PlayerName { get; set; } public string PlayerName { get; set; }
public JoinGameResponse() public JoinGameResponse()
{ {
Action = ClientAction.JoinGame.ToString(); Action = ClientAction.JoinGame.ToString();
Error = "";
GameName = ""; GameName = "";
PlayerName = ""; PlayerName = "";
} }

View File

@@ -1,15 +1,16 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Gameboard.ShogiUI.Sockets.ServiceModels.Api;
using System.Collections.Generic; using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket
{ {
public class MoveResponse : IResponse public class MoveResponse : IResponse
{ {
public string Action { get; protected set; } public string Action { get; }
public Game Game { get; set; } public string GameName { get; set; }
public WhichPlayer PlayerPerspective { get; set; } /// <summary>
public BoardState BoardState { get; set; } /// The player that made the move.
public IList<Move> MoveHistory { get; set; } /// </summary>
public string PlayerName { get; set; }
public MoveResponse() public MoveResponse()
{ {

View File

@@ -7,7 +7,10 @@ using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Controllers namespace Gameboard.ShogiUI.Sockets.Controllers
@@ -71,10 +74,13 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
{ {
var user = await gameboardManager.ReadUser(User); var user = await gameboardManager.ReadUser(User);
var session = await gameboardRepository.ReadSession(gameName); var session = await gameboardRepository.ReadSession(gameName);
if (session == null)
if (session == null || user == null || (session.Player1 != user.Name && session.Player2 != user.Name))
{ {
throw new UnauthorizedAccessException("User is not seated at this game."); return NotFound();
}
if (user == null || (session.Player1.Id != user.Id && session.Player2?.Id != user.Id))
{
return Forbid("User is not seated at this game.");
} }
var move = request.Move; var move = request.Move;
@@ -92,15 +98,14 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
} }
await communicationManager.BroadcastToPlayers(new MoveResponse await communicationManager.BroadcastToPlayers(new MoveResponse
{ {
BoardState = session.Shogi.ToServiceModel(), GameName = session.Name,
Game = session.ToServiceModel(), PlayerName = user.Id
MoveHistory = session.Shogi.MoveHistory.Select(h => h.ToServiceModel()).ToList(), }, session.Player1.Id, session.Player2?.Id);
PlayerPerspective = user.Name == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2
}, session.Player1, session.Player2);
return Ok(); return Ok();
} }
throw new InvalidOperationException("Illegal move."); return Conflict("Illegal move.");
} }
// TODO: Use JWT tokens for guests so they can authenticate and use API routes, too. // TODO: Use JWT tokens for guests so they can authenticate and use API routes, too.
//[Route("")] //[Route("")]
//public async Task<IActionResult> PostSession([FromBody] PostSession request) //public async Task<IActionResult> PostSession([FromBody] PostSession request)
@@ -125,8 +130,8 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[HttpPost] [HttpPost]
public async Task<IActionResult> PostSession([FromBody] PostSession request) public async Task<IActionResult> PostSession([FromBody] PostSession request)
{ {
var user = await gameboardManager.ReadUser(User); var user = await ReadUserOrThrow();
var session = new Models.SessionMetadata(request.Name, request.IsPrivate, user!.Name); var session = new Models.SessionMetadata(request.Name, request.IsPrivate, user!);
var success = await gameboardRepository.CreateSession(session); var success = await gameboardRepository.CreateSession(session);
if (success) if (success)
@@ -134,7 +139,13 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
await communicationManager.BroadcastToAll(new CreateGameResponse await communicationManager.BroadcastToAll(new CreateGameResponse
{ {
Game = session.ToServiceModel(), Game = session.ToServiceModel(),
PlayerName = user.Name PlayerName = user.Id
}).ContinueWith(cont =>
{
if (cont.Exception != null)
{
Console.Error.WriteLine("Yep");
}
}); });
return Ok(); return Ok();
} }
@@ -148,29 +159,83 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[HttpGet("{gameName}")] [HttpGet("{gameName}")]
public async Task<IActionResult> GetSession([FromRoute] string gameName) public async Task<IActionResult> GetSession([FromRoute] string gameName)
{ {
var user = await gameboardManager.ReadUser(User); var user = await ReadUserOrThrow();
var session = await gameboardRepository.ReadSession(gameName); var session = await gameboardRepository.ReadSession(gameName);
if (session == null) if (session == null)
{ {
return NotFound(); return NotFound();
} }
communicationManager.SubscribeToGame(session, user!.Name); communicationManager.SubscribeToGame(session, user!.Id);
var response = new GetGameResponse() var response = new GetSessionResponse()
{ {
Game = new Models.SessionMetadata(session).ToServiceModel(), Game = new Models.SessionMetadata(session).ToServiceModel(user),
BoardState = session.Shogi.ToServiceModel(), BoardState = session.Shogi.ToServiceModel(),
MoveHistory = session.Shogi.MoveHistory.Select(_ => _.ToServiceModel()).ToList(), MoveHistory = session.Shogi.MoveHistory.Select(_ => _.ToServiceModel()).ToList(),
PlayerPerspective = user.Name == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2 PlayerPerspective = user.Id == session.Player1.Id ? WhichPlayer.Player1 : WhichPlayer.Player2
}; };
return new JsonResult(response); return new JsonResult(response);
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> GetSessions() public async Task<GetSessionsResponse> GetSessions()
{ {
var user = await ReadUserOrThrow();
var sessions = await gameboardRepository.ReadSessionMetadatas(); var sessions = await gameboardRepository.ReadSessionMetadatas();
return new JsonResult(sessions.Select(s => s.ToServiceModel()).ToList());
var sessionsJoinedByUser = sessions
.Where(s => s.IsSeated(user))
.Select(s => s.ToServiceModel())
.ToList();
var sessionsNotJoinedByUser = sessions
.Where(s => !s.IsSeated(user))
.Select(s => s.ToServiceModel())
.ToList();
return new GetSessionsResponse
{
PlayerHasJoinedSessions = new Collection<Game>(sessionsJoinedByUser),
AllOtherSessions = new Collection<Game>(sessionsNotJoinedByUser)
};
}
[HttpPut("{gameName}")]
public async Task<IActionResult> PutJoinSession([FromRoute] string gameName)
{
var user = await ReadUserOrThrow();
var session = await gameboardRepository.ReadSessionMetaData(gameName);
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);
var success = await gameboardRepository.UpdateSession(session);
if (!success) return this.Problem(detail: "Unable to update session.");
var opponentName = user.Id == session.Player1.Id
? session.Player2!.Id
: session.Player1.Id;
await communicationManager.BroadcastToPlayers(new JoinGameResponse
{
GameName = session.Name,
PlayerName = user.Id
}, opponentName);
return Ok();
}
private async Task<Models.User> ReadUserOrThrow()
{
var user = await gameboardManager.ReadUser(User);
if (user == null)
{
throw new UnauthorizedAccessException("Unknown user claims.");
}
return user;
} }
} }
} }

View File

@@ -54,24 +54,21 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[HttpGet("Token")] [HttpGet("Token")]
public async Task<IActionResult> GetToken() public async Task<IActionResult> GetToken()
{ {
var identityId = User.UserId();
if (string.IsNullOrWhiteSpace(identityId))
{
return Unauthorized();
}
var user = await gameboardManager.ReadUser(User); var user = await gameboardManager.ReadUser(User);
if (user == null) if (user == null)
{ {
user = new User(identityId); if (await gameboardManager.CreateUser(User))
var success = await gameboardRepository.CreateUser(user);
if (!success)
{ {
return Unauthorized(); user = await gameboardManager.ReadUser(User);
} }
} }
var token = tokenCache.GenerateToken(user.Name); if (user == null)
{
return Unauthorized();
}
var token = tokenCache.GenerateToken(user.Id);
return new JsonResult(new GetTokenResponse(token)); return new JsonResult(new GetTokenResponse(token));
} }
@@ -79,35 +76,28 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetGuestToken() public async Task<IActionResult> GetGuestToken()
{ {
if (Guid.TryParse(User.UserId(), out Guid webSessionId)) var user = await gameboardManager.ReadUser(User);
if (user == null)
{ {
var user = await gameboardRepository.ReadGuestUser(webSessionId); // Create a guest user.
if (user != null) var newUser = Models.User.CreateGuestUser(Guid.NewGuid().ToString());
var success = await gameboardRepository.CreateUser(newUser);
if (!success)
{ {
var token = tokenCache.GenerateToken(webSessionId.ToString()); return Conflict();
return new JsonResult(new GetGuestTokenResponse(user.Name, token));
} }
}
else var identity = newUser.CreateClaimsIdentity();
{ await HttpContext.SignInAsync(
// Setup a guest user.
var newSessionId = Guid.NewGuid();
var user = new User(Guid.NewGuid().ToString(), newSessionId);
if (await gameboardRepository.CreateUser(user))
{
var identity = user.CreateGuestUserIdentity();
await this.HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme, CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity), new ClaimsPrincipal(identity),
authenticationProps authenticationProps
); );
user = newUser;
var token = tokenCache.GenerateToken(newSessionId.ToString());
return new JsonResult(new GetGuestTokenResponse(user.Name, token));
}
} }
return Unauthorized(); var token = tokenCache.GenerateToken(user.Id.ToString());
return this.Ok(new GetGuestTokenResponse(user.Id, user.DisplayName, token));
} }
} }
} }

View File

@@ -10,9 +10,19 @@ namespace Gameboard.ShogiUI.Sockets.Extensions
return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value; 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 IsGuest(this ClaimsPrincipal self) public static bool IsGuest(this ClaimsPrincipal self)
{ {
return self.HasClaim(c => c.Type == ClaimTypes.Role && c.Value == "Guest"); return self.HasClaim(c => c.Type == ClaimTypes.Role && c.Value == "Guest");
} }
public static string ToCamelCase(this string self)
{
return char.ToLowerInvariant(self[0]) + self[1..];
}
} }
} }

View File

@@ -1,43 +0,0 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers
{
public interface IJoinGameHandler
{
Task Handle(JoinGameRequest request, string userName);
}
public class JoinGameHandler : IJoinGameHandler
{
private readonly IGameboardManager gameboardManager;
private readonly ISocketConnectionManager connectionManager;
public JoinGameHandler(
ISocketConnectionManager communicationManager,
IGameboardManager gameboardManager)
{
this.gameboardManager = gameboardManager;
this.connectionManager = communicationManager;
}
public async Task Handle(JoinGameRequest request, string userName)
{
var joinSucceeded = await gameboardManager.AssignPlayer2ToSession(request.GameName, userName);
var response = new JoinGameResponse()
{
PlayerName = userName,
GameName = request.GameName
};
if (joinSucceeded)
{
await connectionManager.BroadcastToAll(response);
}
else
{
response.Error = "Game is full or does not exist.";
await connectionManager.BroadcastToPlayers(response, userName);
}
}
}
}

View File

@@ -9,9 +9,9 @@ namespace Gameboard.ShogiUI.Sockets.Managers
{ {
public interface IGameboardManager public interface IGameboardManager
{ {
Task<bool> IsPlayer1(string sessionName, string playerName); Task<bool> AssignPlayer2ToSession(string sessionName, User user);
Task<bool> AssignPlayer2ToSession(string sessionName, string userName);
Task<User?> ReadUser(ClaimsPrincipal user); Task<User?> ReadUser(ClaimsPrincipal user);
Task<bool> CreateUser(ClaimsPrincipal user);
} }
public class GameboardManager : IGameboardManager public class GameboardManager : IGameboardManager
@@ -23,14 +23,25 @@ namespace Gameboard.ShogiUI.Sockets.Managers
this.repository = repository; this.repository = repository;
} }
public Task<User?> ReadUser(ClaimsPrincipal user) public Task<bool> CreateUser(ClaimsPrincipal principal)
{ {
var userId = user.UserId(); var id = principal.UserId();
if (user.IsGuest() && Guid.TryParse(userId, out var webSessionId)) if (string.IsNullOrEmpty(id))
{ {
return repository.ReadGuestUser(webSessionId); return Task.FromResult(false);
} }
else if (!string.IsNullOrEmpty(userId))
var user = principal.IsGuest()
? User.CreateGuestUser(id)
: User.CreateMsalUser(id);
return repository.CreateUser(user);
}
public Task<User?> ReadUser(ClaimsPrincipal principal)
{
var userId = principal.UserId();
if (!string.IsNullOrEmpty(userId))
{ {
return repository.ReadUser(userId); return repository.ReadUser(userId);
} }
@@ -38,12 +49,6 @@ namespace Gameboard.ShogiUI.Sockets.Managers
return Task.FromResult<User?>(null); return Task.FromResult<User?>(null);
} }
public async Task<bool> IsPlayer1(string sessionName, string playerName)
{
//var session = await repository.GetGame(sessionName);
//return session?.Player1 == playerName;
return true;
}
public async Task<string> CreateJoinCode(string sessionName, string playerName) public async Task<string> CreateJoinCode(string sessionName, string playerName)
{ {
@@ -55,19 +60,15 @@ namespace Gameboard.ShogiUI.Sockets.Managers
return string.Empty; return string.Empty;
} }
public async Task<bool> AssignPlayer2ToSession(string sessionName, string userName) public async Task<bool> AssignPlayer2ToSession(string sessionName, User user)
{ {
var isSuccess = false;
var session = await repository.ReadSessionMetaData(sessionName); var session = await repository.ReadSessionMetaData(sessionName);
if (session != null && !session.IsPrivate && string.IsNullOrEmpty(session.Player2)) if (session != null && !session.IsPrivate && session.Player2 == null)
{ {
session.SetPlayer2(userName); session.SetPlayer2(user);
if (await repository.UpdateSession(session)) return await repository.UpdateSession(session);
{
isSuccess = true;
} }
} return false;
return isSuccess;
} }
} }
} }

View File

@@ -3,6 +3,7 @@ using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.WebSockets; using System.Net.WebSockets;
@@ -106,9 +107,32 @@ namespace Gameboard.ShogiUI.Sockets.Managers
foreach (var kvp in connections) foreach (var kvp in connections)
{ {
var socket = kvp.Value; var socket = kvp.Value;
try
{
tasks.Add(socket.SendTextAsync(message)); tasks.Add(socket.SendTextAsync(message));
} }
return Task.WhenAll(tasks); catch (WebSocketException webSocketException)
{
logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
UnsubscribeFromBroadcastAndGames(kvp.Key);
}
catch (Exception exception)
{
logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
UnsubscribeFromBroadcastAndGames(kvp.Key);
}
}
try
{
var task = Task.WhenAll(tasks);
return task;
}
catch (Exception e)
{
Console.WriteLine("Yo");
}
return Task.FromResult(0);
} }
//public Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2) //public Task BroadcastToGame(string gameName, IResponse forPlayer1, IResponse forPlayer2)

View File

@@ -10,13 +10,14 @@ namespace Gameboard.ShogiUI.Sockets.Models
// TODO: Separate subscriptions to the Session from the Session. // TODO: Separate subscriptions to the Session from the Session.
[JsonIgnore] public ConcurrentDictionary<string, WebSocket> Subscriptions { get; } [JsonIgnore] public ConcurrentDictionary<string, WebSocket> Subscriptions { get; }
public string Name { get; } public string Name { get; }
public string Player1 { get; } public User Player1 { get; }
public string? Player2 { get; private set; } public User? Player2 { get; private set; }
public bool IsPrivate { get; } public bool IsPrivate { get; }
// TODO: Don't retain the entire rules system within the Session model. It just needs the board state after rules are applied.
public Shogi Shogi { get; } public Shogi Shogi { get; }
public Session(string name, bool isPrivate, Shogi shogi, string player1, string? player2 = null) public Session(string name, bool isPrivate, Shogi shogi, User player1, User? player2 = null)
{ {
Subscriptions = new ConcurrentDictionary<string, WebSocket>(); Subscriptions = new ConcurrentDictionary<string, WebSocket>();
@@ -27,11 +28,11 @@ namespace Gameboard.ShogiUI.Sockets.Models
Shogi = shogi; Shogi = shogi;
} }
public void SetPlayer2(string userName) public void SetPlayer2(User user)
{ {
Player2 = userName; Player2 = user;
} }
public Game ToServiceModel() => new() { GameName = Name, Player1 = Player1, Player2 = Player2 }; public Game ToServiceModel() => new() { GameName = Name, Player1 = Player1.DisplayName, Player2 = Player2?.DisplayName };
} }
} }

View File

@@ -6,11 +6,11 @@
public class SessionMetadata public class SessionMetadata
{ {
public string Name { get; } public string Name { get; }
public string Player1 { get; } public User Player1 { get; }
public string? Player2 { get; private set; } public User? Player2 { get; private set; }
public bool IsPrivate { get; } public bool IsPrivate { get; }
public SessionMetadata(string name, bool isPrivate, string player1, string? player2 = null) public SessionMetadata(string name, bool isPrivate, User player1, User? player2 = null)
{ {
Name = name; Name = name;
IsPrivate = isPrivate; IsPrivate = isPrivate;
@@ -25,11 +25,27 @@
Player2 = sessionModel.Player2; Player2 = sessionModel.Player2;
} }
public void SetPlayer2(string playerName) public void SetPlayer2(User user)
{ {
Player2 = playerName; Player2 = user;
} }
public ServiceModels.Types.Game ToServiceModel() => new(Name, Player1, Player2); public bool IsSeated(User user) => user.Id == Player1.Id || user.Id == Player2?.Id;
public ServiceModels.Types.Game ToServiceModel(User? user = null)
{
// TODO: Find a better way for the UI to know whether or not they are seated at a given game than client-side ID matching.
var player1 = Player1.DisplayName;
var player2 = Player2?.DisplayName;
if (user != null)
{
if (user.Id == Player1.Id) player1 = Player1.Id;
if (Player2 != null && user.Id == Player2.Id)
{
player2 = Player2.DisplayName;
}
}
return new(Name, player1, player2);
}
} }
} }

View File

@@ -1,57 +1,79 @@
using Microsoft.AspNetCore.Authentication.Cookies; using Gameboard.ShogiUI.Sockets.Repositories.CouchModels;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Security.Claims; using System.Security.Claims;
namespace Gameboard.ShogiUI.Sockets.Models namespace Gameboard.ShogiUI.Sockets.Models
{ {
public class User public class User
{ {
public string Name { get; } public static readonly ReadOnlyCollection<string> Adjectives = new(new[] {
public Guid? WebSessionId { get; } "Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly"
});
public bool IsGuest => WebSessionId.HasValue; public static readonly ReadOnlyCollection<string> Subjects = new(new[] {
"Hippo", "Basil", "Mouse", "Walnut", "Prince", "Lima Bean", "Coala", "Potato"
public User(string name) });
public static User CreateMsalUser(string id) => new(id, id, WhichLoginPlatform.Microsoft);
public static User CreateGuestUser(string id)
{ {
Name = name; 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);
} }
/// <summary> public string Id { get; }
/// Constructor for guest user. public string DisplayName { get; }
/// </summary>
public User(string name, Guid webSessionId) public WhichLoginPlatform LoginPlatform { get; }
public bool IsGuest => LoginPlatform == WhichLoginPlatform.Guest;
public User(string id, string displayName, WhichLoginPlatform platform)
{ {
Name = name; Id = id;
WebSessionId = webSessionId; DisplayName = displayName;
LoginPlatform = platform;
} }
public ClaimsIdentity CreateMsalUserIdentity() public User(UserDocument document)
{ {
var claims = new List<Claim>() Id = document.Id;
{ DisplayName = document.DisplayName;
new Claim(ClaimTypes.NameIdentifier, Name), LoginPlatform = document.Platform;
new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers.
};
return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
} }
public ClaimsIdentity CreateGuestUserIdentity() public ClaimsIdentity CreateClaimsIdentity()
{ {
// TODO: Make this method static and factory-like. if (LoginPlatform == WhichLoginPlatform.Guest)
if (!WebSessionId.HasValue)
{ {
throw new InvalidOperationException("Cannot create guest identity without a session identifier."); var claims = new List<Claim>(4)
}
var claims = new List<Claim>()
{ {
new Claim(ClaimTypes.NameIdentifier, WebSessionId.Value.ToString()), new Claim(ClaimTypes.NameIdentifier, Id),
new Claim(ClaimTypes.Name, DisplayName),
new Claim(ClaimTypes.Role, "Guest"), new Claim(ClaimTypes.Role, "Guest"),
new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers. new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers.
}; };
return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
} }
else
{
var claims = new List<Claim>(3)
{
new Claim(ClaimTypes.NameIdentifier, Id),
new Claim(ClaimTypes.Name, DisplayName),
new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers.
};
return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
}
}
} }
} }

View File

@@ -2,6 +2,7 @@
{ {
public enum WhichLoginPlatform public enum WhichLoginPlatform
{ {
Unknown,
Microsoft, Microsoft,
Guest Guest
} }

View File

@@ -1,5 +1,4 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Gameboard.ShogiUI.Sockets.Utilities;
using Gameboard.ShogiUI.Sockets.Utilities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;

View File

@@ -6,6 +6,7 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
public abstract class CouchDocument public abstract class CouchDocument
{ {
[JsonProperty("_id")] public string Id { get; set; } [JsonProperty("_id")] public string Id { get; set; }
[JsonProperty("_rev")] public string? RevisionId { get; set; }
public WhichDocumentType DocumentType { get; } public WhichDocumentType DocumentType { get; }
public DateTimeOffset CreatedDate { get; set; } public DateTimeOffset CreatedDate { get; set; }

View File

@@ -0,0 +1,28 @@
using System;
namespace Gameboard.ShogiUI.Sockets.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!;
}
}
}

View File

@@ -5,8 +5,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
public class SessionDocument : CouchDocument public class SessionDocument : CouchDocument
{ {
public string Name { get; set; } public string Name { get; set; }
public string Player1 { get; set; } public string Player1Id { get; set; }
public string? Player2 { get; set; } public string? Player2Id { get; set; }
public bool IsPrivate { get; set; } public bool IsPrivate { get; set; }
public IList<BoardStateDocument> History { get; set; } public IList<BoardStateDocument> History { get; set; }
@@ -16,8 +16,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
public SessionDocument() : base(WhichDocumentType.Session) public SessionDocument() : base(WhichDocumentType.Session)
{ {
Name = string.Empty; Name = string.Empty;
Player1 = string.Empty; Player1Id = string.Empty;
Player2 = string.Empty; Player2Id = string.Empty;
History = new List<BoardStateDocument>(0); History = new List<BoardStateDocument>(0);
} }
@@ -25,8 +25,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
: base(session.Name, WhichDocumentType.Session) : base(session.Name, WhichDocumentType.Session)
{ {
Name = session.Name; Name = session.Name;
Player1 = session.Player1; Player1Id = session.Player1.Id;
Player2 = session.Player2; Player2Id = session.Player2?.Id;
IsPrivate = session.IsPrivate; IsPrivate = session.IsPrivate;
History = new List<BoardStateDocument>(0); History = new List<BoardStateDocument>(0);
} }
@@ -35,14 +35,10 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
: base(sessionMetaData.Name, WhichDocumentType.Session) : base(sessionMetaData.Name, WhichDocumentType.Session)
{ {
Name = sessionMetaData.Name; Name = sessionMetaData.Name;
Player1 = sessionMetaData.Player1; Player1Id = sessionMetaData.Player1.Id;
Player2 = sessionMetaData.Player2; Player2Id = sessionMetaData.Player2?.Id;
IsPrivate = sessionMetaData.IsPrivate; IsPrivate = sessionMetaData.IsPrivate;
History = new List<BoardStateDocument>(0); History = new List<BoardStateDocument>(0);
} }
public Models.Session ToDomainModel(Models.Shogi shogi) => new(Name, IsPrivate, shogi, Player1, Player2);
public Models.SessionMetadata ToDomainModel() => new(Name, IsPrivate, Player1, Player2);
} }
} }

View File

@@ -1,34 +1,27 @@
using Gameboard.ShogiUI.Sockets.Models; using Gameboard.ShogiUI.Sockets.Models;
using System;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{ {
public class UserDocument : CouchDocument public class UserDocument : CouchDocument
{ {
public string DisplayName { get; set; }
public string Name { get; set; }
public WhichLoginPlatform Platform { get; set; } public WhichLoginPlatform Platform { get; set; }
/// <summary>
/// The browser session ID saved via Set-Cookie headers.
/// Only used with guest accounts.
/// </summary>
public Guid? WebSessionId { get; set; }
/// <summary> /// <summary>
/// Constructor for JSON deserializing. /// Constructor for JSON deserializing.
/// </summary> /// </summary>
public UserDocument() : base(WhichDocumentType.User) public UserDocument() : base(WhichDocumentType.User)
{ {
Name = string.Empty; DisplayName = string.Empty;
} }
public UserDocument(string name, Guid? webSessionId = null) : base($"org.couchdb.user:{name}", WhichDocumentType.User) public UserDocument(
string id,
string displayName,
WhichLoginPlatform platform) : base(id, WhichDocumentType.User)
{ {
Name = name; DisplayName = displayName;
WebSessionId = webSessionId; Platform = platform;
Platform = WebSessionId.HasValue
? WhichLoginPlatform.Guest
: WhichLoginPlatform.Microsoft;
} }
} }
} }

View File

@@ -1,8 +1,12 @@
using Gameboard.ShogiUI.Sockets.Repositories.CouchModels; using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Repositories.CouchModels;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
@@ -16,10 +20,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
Task<bool> CreateBoardState(Models.Session session); Task<bool> CreateBoardState(Models.Session session);
Task<bool> CreateSession(Models.SessionMetadata session); Task<bool> CreateSession(Models.SessionMetadata session);
Task<bool> CreateUser(Models.User user); Task<bool> CreateUser(Models.User user);
Task<IList<Models.SessionMetadata>> ReadSessionMetadatas(); Task<Collection<Models.SessionMetadata>> ReadSessionMetadatas();
Task<Models.User?> ReadGuestUser(Guid webSessionId);
Task<Models.Session?> ReadSession(string name); Task<Models.Session?> ReadSession(string name);
Task<Models.Shogi?> ReadShogi(string name);
Task<bool> UpdateSession(Models.SessionMetadata session); Task<bool> UpdateSession(Models.SessionMetadata session);
Task<Models.SessionMetadata?> ReadSessionMetaData(string name); Task<Models.SessionMetadata?> ReadSessionMetaData(string name);
Task<Models.User?> ReadUser(string userName); Task<Models.User?> ReadUser(string userName);
@@ -27,6 +29,15 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
public class GameboardRepository : IGameboardRepository public class GameboardRepository : IGameboardRepository
{ {
/// <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 const string ApplicationJson = "application/json";
private readonly HttpClient client; private readonly HttpClient client;
private readonly ILogger<GameboardRepository> logger; private readonly ILogger<GameboardRepository> logger;
@@ -37,87 +48,124 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
this.logger = logger; this.logger = logger;
} }
public async Task<IList<Models.SessionMetadata>> ReadSessionMetadatas() public async Task<Collection<Models.SessionMetadata>> ReadSessionMetadatas()
{ {
var selector = new Dictionary<string, object>(2) var queryParams = new QueryBuilder { { "include_docs", "true" } }.ToQueryString();
{ var response = await client.GetAsync($"{View_SessionMetadata}{queryParams}");
[nameof(SessionDocument.DocumentType)] = WhichDocumentType.Session
};
var q = new { Selector = selector };
var content = new StringContent(JsonConvert.SerializeObject(q), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync("_find", content);
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
var results = JsonConvert.DeserializeObject<CouchFindResult<SessionDocument>>(responseContent); var result = JsonConvert.DeserializeObject<CouchViewResult<JObject>>(responseContent);
if (results != null) if (result != null)
{ {
return results var groupedBySession = result.rows.GroupBy(row => row.id);
.docs var sessions = new List<Models.SessionMetadata>(result.total_rows / 3);
.Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2)) foreach (var group in groupedBySession)
.ToList(); {
/**
* 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 player1Doc = group.Skip(1).FirstOrDefault()?.doc.ToObject<UserDocument>();
var player2Doc = group.Skip(2).FirstOrDefault()?.doc.ToObject<UserDocument>();
if (session != null && player1Doc != null)
{
var player2 = player2Doc == null ? null : new Models.User(player2Doc);
sessions.Add(new Models.SessionMetadata(session.Name, session.IsPrivate, new(player1Doc), player2));
} }
}
return new List<Models.SessionMetadata>(0); return new Collection<Models.SessionMetadata>(sessions);
}
return new Collection<Models.SessionMetadata>(Array.Empty<Models.SessionMetadata>());
} }
public async Task<Models.Session?> ReadSession(string name) public async Task<Models.Session?> ReadSession(string name)
{ {
var readShogiTask = ReadShogi(name); var queryParams = new QueryBuilder
var response = await client.GetAsync(name);
var responseContent = await response.Content.ReadAsStringAsync();
var couchModel = JsonConvert.DeserializeObject<SessionDocument>(responseContent);
var shogi = await readShogiTask;
if (shogi == null)
{ {
return null; { "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 3 type of elements.
* 1) The session metadata.
* 2) User documents of Player1 and Player2.
* 2.a) If the Player2 document doesn't exist, CouchDB will return the SessionDocument instead :(
* 3) BoardState
*/
var session = group[0].doc.ToObject<SessionDocument>();
var player1Doc = group[1].doc.ToObject<UserDocument>();
var group2DocumentType = group[2].doc.Property(nameof(UserDocument.DocumentType).ToCamelCase())?.Value.Value<string>();
var player2Doc = group2DocumentType == WhichDocumentType.User.ToString()
? group[2].doc.ToObject<UserDocument>()
: null;
var moves = group
.Skip(4) // Skip 4 because group[3] will not have a .Move property since it's the first/initial BoardState of the session.
// TODO: Deserialize just the Move property.
.Select(row => row.doc.ToObject<BoardStateDocument>())
.Select(boardState =>
{
var move = boardState!.Move!;
return move.PieceFromHand.HasValue
? new Models.Move(move.PieceFromHand.Value, move.To)
: new Models.Move(move.From!, move.To, move.IsPromotion);
})
.ToList();
var shogi = new Models.Shogi(moves);
if (session != null && player1Doc != null)
{
var player2 = player2Doc == null ? null : new Models.User(player2Doc);
return new Models.Session(session.Name, session.IsPrivate, shogi, new(player1Doc), player2);
} }
return couchModel.ToDomainModel(shogi); }
return null;
} }
public async Task<Models.SessionMetadata?> ReadSessionMetaData(string name) public async Task<Models.SessionMetadata?> ReadSessionMetaData(string name)
{ {
var response = await client.GetAsync(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 responseContent = await response.Content.ReadAsStringAsync();
var couchModel = JsonConvert.DeserializeObject<SessionDocument>(responseContent); var result = JsonConvert.DeserializeObject<CouchViewResult<JObject>>(responseContent);
return couchModel.ToDomainModel(); 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 player1Doc = group[1].doc.ToObject<UserDocument>();
var group2DocumentType = group[2].doc.Property(nameof(UserDocument.DocumentType).ToCamelCase())?.Value.Value<string>();
var player2Doc = group2DocumentType == WhichDocumentType.User.ToString()
? group[2].doc.ToObject<UserDocument>()
: null;
if (session != null && player1Doc != null)
{
var player2 = player2Doc == null ? null : new Models.User(player2Doc);
return new Models.SessionMetadata(session.Name, session.IsPrivate, new(player1Doc), player2);
}
} }
public async Task<Models.Shogi?> ReadShogi(string name)
{
var selector = new Dictionary<string, object>(2)
{
[nameof(BoardStateDocument.DocumentType)] = WhichDocumentType.BoardState,
[nameof(BoardStateDocument.Name)] = name
};
var sort = new Dictionary<string, object>(1)
{
[nameof(BoardStateDocument.CreatedDate)] = "asc"
};
var query = JsonConvert.SerializeObject(new { selector, sort = new[] { sort } });
var content = new StringContent(query, Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync("_find", content);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
logger.LogError("Couch error during _find in {func}: {error}.\n\nQuery: {query}", nameof(ReadShogi), responseContent, query);
return null; return null;
} }
var boardStates = JsonConvert
.DeserializeObject<CouchFindResult<BoardStateDocument>>(responseContent)
.docs;
if (boardStates.Length == 0) return null;
// Skip(1) because the first BoardState has no move; it represents the initial board state of a new Session.
var moves = boardStates.Skip(1).Select(couchModel =>
{
var move = couchModel.Move;
Models.Move model = move!.PieceFromHand.HasValue
? new Models.Move(move.PieceFromHand.Value, move.To)
: new Models.Move(move.From!, move.To, move.IsPromotion);
return model;
}).ToList();
return new Models.Shogi(moves);
}
/// <summary> /// <summary>
/// Saves a snapshot of board state and the most recent move. /// Saves a snapshot of board state and the most recent move.
@@ -149,7 +197,16 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
public async Task<bool> UpdateSession(Models.SessionMetadata session) public async Task<bool> UpdateSession(Models.SessionMetadata session)
{ {
var couchModel = new SessionDocument(session); // GET existing session to get revisionId.
var readResponse = await client.GetAsync(session.Name);
if (!readResponse.IsSuccessStatusCode) return false;
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 content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson);
var response = await client.PutAsync(couchModel.Id, content); var response = await client.PutAsync(couchModel.Id, content);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
@@ -205,66 +262,31 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
return string.Empty; return string.Empty;
} }
public async Task<Models.User?> ReadUser(string userName) public async Task<Models.User?> ReadUser(string id)
{ {
try var queryParams = new QueryBuilder
{ {
var document = new UserDocument(userName); { "include_docs", "true" },
var uri = new Uri(client.BaseAddress!, HttpUtility.UrlEncode(document.Id)); { "key", JsonConvert.SerializeObject(id) },
var response = await client.GetAsync(HttpUtility.UrlEncode(document.Id)); }.ToQueryString();
var response2 = await client.GetAsync(uri); var response = await client.GetAsync($"{View_User}{queryParams}");
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode) var result = JsonConvert.DeserializeObject<CouchViewResult<UserDocument>>(responseContent);
if (result != null && result.rows.Length > 0)
{ {
var user = JsonConvert.DeserializeObject<UserDocument>(responseContent); return new Models.User(result.rows[0].doc);
}
return new Models.User(user.Name);
}
}
catch (Exception e)
{
Console.WriteLine(e);
}
return null; return null;
} }
public async Task<bool> CreateUser(Models.User user) public async Task<bool> CreateUser(Models.User user)
{ {
var couchModel = new UserDocument(user.Name, user.WebSessionId); var couchModel = new UserDocument(user.Id, user.DisplayName, user.LoginPlatform);
var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson); var content = new StringContent(JsonConvert.SerializeObject(couchModel), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync(string.Empty, content); var response = await client.PostAsync(string.Empty, content);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
public async Task<Models.User?> ReadGuestUser(Guid webSessionId)
{
var selector = new Dictionary<string, object>(2)
{
[nameof(UserDocument.DocumentType)] = WhichDocumentType.User,
[nameof(UserDocument.WebSessionId)] = webSessionId.ToString()
};
var query = JsonConvert.SerializeObject(new { selector, limit = 1 });
var content = new StringContent(query, Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync("_find", content);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
logger.LogError("Couch error during _find in {func}: {error}.\n\nQuery: {query}", nameof(ReadGuestUser), responseContent, query);
return null;
}
var result = JsonConvert.DeserializeObject<CouchFindResult<UserDocument>>(responseContent);
if (!string.IsNullOrWhiteSpace(result.warning))
{
logger.LogError(new InvalidOperationException(result.warning), result.warning);
return null;
}
var userDocument = result.docs.SingleOrDefault();
if (userDocument != null)
{
return new Models.User(userDocument.Name);
}
return null;
}
} }
} }

View File

@@ -1,5 +1,4 @@
using FluentValidation; using FluentValidation;
using Gameboard.ShogiUI.Sockets.Controllers;
using Gameboard.ShogiUI.Sockets.Extensions; using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers; using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers; using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers;
@@ -34,7 +33,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
private readonly IGameboardManager gameboardManager; private readonly IGameboardManager gameboardManager;
private readonly ISocketTokenCache tokenManager; private readonly ISocketTokenCache tokenManager;
private readonly IJoinByCodeHandler joinByCodeHandler; private readonly IJoinByCodeHandler joinByCodeHandler;
private readonly IJoinGameHandler joinGameHandler;
private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator; private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator;
private readonly IValidator<JoinGameRequest> joinGameRequestValidator; private readonly IValidator<JoinGameRequest> joinGameRequestValidator;
@@ -45,7 +43,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
IGameboardManager gameboardManager, IGameboardManager gameboardManager,
ISocketTokenCache tokenManager, ISocketTokenCache tokenManager,
IJoinByCodeHandler joinByCodeHandler, IJoinByCodeHandler joinByCodeHandler,
IJoinGameHandler joinGameHandler,
IValidator<JoinByCodeRequest> joinByCodeRequestValidator, IValidator<JoinByCodeRequest> joinByCodeRequestValidator,
IValidator<JoinGameRequest> joinGameRequestValidator IValidator<JoinGameRequest> joinGameRequestValidator
) : base() ) : base()
@@ -56,34 +53,25 @@ namespace Gameboard.ShogiUI.Sockets.Services
this.gameboardManager = gameboardManager; this.gameboardManager = gameboardManager;
this.tokenManager = tokenManager; this.tokenManager = tokenManager;
this.joinByCodeHandler = joinByCodeHandler; this.joinByCodeHandler = joinByCodeHandler;
this.joinGameHandler = joinGameHandler;
this.joinByCodeRequestValidator = joinByCodeRequestValidator; this.joinByCodeRequestValidator = joinByCodeRequestValidator;
this.joinGameRequestValidator = joinGameRequestValidator; this.joinGameRequestValidator = joinGameRequestValidator;
} }
public async Task HandleSocketRequest(HttpContext context) public async Task HandleSocketRequest(HttpContext context)
{ {
string? userName = null; if (!context.Request.Query.Keys.Contains("token"))
var user = await gameboardManager.ReadUser(context.User);
if (user?.WebSessionId != null)
{ {
// Guest account context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
userName = tokenManager.GetUsername(user.WebSessionId.Value); return;
} }
else if (context.Request.Query.Keys.Contains("token"))
{
// Microsoft account
var token = Guid.Parse(context.Request.Query["token"][0]); var token = Guid.Parse(context.Request.Query["token"][0]);
userName = tokenManager.GetUsername(token); var userName = tokenManager.GetUsername(token);
}
if (string.IsNullOrEmpty(userName)) if (string.IsNullOrEmpty(userName))
{ {
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return; return;
} }
else
{
var socket = await context.WebSockets.AcceptWebSocketAsync(); var socket = await context.WebSockets.AcceptWebSocketAsync();
communicationManager.SubscribeToBroadcast(socket, userName); communicationManager.SubscribeToBroadcast(socket, userName);
@@ -95,31 +83,25 @@ namespace Gameboard.ShogiUI.Sockets.Services
if (string.IsNullOrWhiteSpace(message)) continue; if (string.IsNullOrWhiteSpace(message)) continue;
logger.LogInformation("Request \n{0}\n", message); logger.LogInformation("Request \n{0}\n", message);
var request = JsonConvert.DeserializeObject<Request>(message); var request = JsonConvert.DeserializeObject<Request>(message);
if (!Enum.IsDefined(typeof(ClientAction), request.Action)) if (request == null || !Enum.IsDefined(typeof(ClientAction), request.Action))
{ {
await socket.SendTextAsync("Error: Action not recognized."); await socket.SendTextAsync("Error: Action not recognized.");
continue; continue;
} }
switch (request.Action) switch (request.Action)
{ {
case ClientAction.JoinGame:
{
var req = JsonConvert.DeserializeObject<JoinGameRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, joinGameRequestValidator, req))
{
await joinGameHandler.Handle(req, userName);
}
break;
}
case ClientAction.JoinByCode: case ClientAction.JoinByCode:
{ {
var req = JsonConvert.DeserializeObject<JoinByCodeRequest>(message); var req = JsonConvert.DeserializeObject<JoinByCodeRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, joinByCodeRequestValidator, req)) if (req != null && await ValidateRequestAndReplyIfInvalid(socket, joinByCodeRequestValidator, req))
{ {
await joinByCodeHandler.Handle(req, userName); await joinByCodeHandler.Handle(req, userName);
} }
break; break;
} }
default:
await socket.SendTextAsync($"Received your message with action {request.Action}, but did no work.");
break;
} }
} }
catch (OperationCanceledException ex) catch (OperationCanceledException ex)
@@ -132,9 +114,7 @@ namespace Gameboard.ShogiUI.Sockets.Services
logger.LogInformation("Probably tried writing to a closed socket."); logger.LogInformation("Probably tried writing to a closed socket.");
logger.LogError(ex.Message); logger.LogError(ex.Message);
} }
}
communicationManager.UnsubscribeFromBroadcastAndGames(userName); communicationManager.UnsubscribeFromBroadcastAndGames(userName);
return;
} }
} }
@@ -144,11 +124,7 @@ namespace Gameboard.ShogiUI.Sockets.Services
if (!results.IsValid) if (!results.IsValid)
{ {
var errors = string.Join('\n', results.Errors.Select(_ => _.ErrorMessage)); var errors = string.Join('\n', results.Errors.Select(_ => _.ErrorMessage));
var message = JsonConvert.SerializeObject(new Response await socket.SendTextAsync(errors);
{
Error = errors
});
await socket.SendTextAsync(message);
} }
return results.IsValid; return results.IsValid;
} }

View File

@@ -5,6 +5,5 @@ namespace Gameboard.ShogiUI.Sockets.Services.Utility
public class Response : IResponse public class Response : IResponse
{ {
public string Action { get; set; } public string Action { get; set; }
public string Error { get; set; }
} }
} }

View File

@@ -30,14 +30,14 @@ namespace Gameboard.ShogiUI.Sockets
var user = await gameboardRepository.ReadUser(nameClaim.Value); var user = await gameboardRepository.ReadUser(nameClaim.Value);
if (user == null) if (user == null)
{ {
var newUser = new Models.User(nameClaim.Value); var newUser = Models.User.CreateMsalUser(nameClaim.Value);
var success = await gameboardRepository.CreateUser(newUser); var success = await gameboardRepository.CreateUser(newUser);
if (success) user = newUser; if (success) user = newUser;
} }
if (user != null) if (user != null)
{ {
return new ClaimsPrincipal(user.CreateMsalUserIdentity()); return new ClaimsPrincipal(user.CreateClaimsIdentity());
} }
} }
return principal; return principal;

View File

@@ -13,7 +13,9 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Http;
using Microsoft.Identity.Client; using Microsoft.Identity.Client;
using Microsoft.Identity.Web; using Microsoft.Identity.Web;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -41,7 +43,6 @@ namespace Gameboard.ShogiUI.Sockets
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>(); services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>();
services.AddSingleton<IJoinGameHandler, JoinGameHandler>();
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>(); services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<ISocketTokenCache, SocketTokenCache>(); services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<IGameboardManager, GameboardManager>(); services.AddSingleton<IGameboardManager, GameboardManager>();
@@ -109,6 +110,9 @@ namespace Gameboard.ShogiUI.Sockets
document.Info.Title = "Gameboard.ShogiUI.Sockets"; document.Info.Title = "Gameboard.ShogiUI.Sockets";
}; };
}); });
// Remove default HttpClient logging.
services.RemoveAll<IHttpMessageHandlerBuilderFilter>();
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@@ -7,9 +7,9 @@
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Warning",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Error"
} }
}, },
"AzureAd": { "AzureAd": {