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
{
public class GetGuestToken
{
}
public class GetGuestTokenResponse
{
public string PlayerName { get; }
public string UserId { get; }
public string DisplayName { 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;
}
}

View File

@@ -1,13 +1,9 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{
public class GetGameResponse
public class GetSessionResponse
{
public Game Game { 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 string Action { get; protected set; }
public string Error { get; set; }
public string GameName { get; set; }
/// <summary>
/// The player who joined the game.
/// </summary>
public string PlayerName { get; set; }
public JoinGameResponse()
{
Action = ClientAction.JoinGame.ToString();
Error = "";
GameName = "";
PlayerName = "";
}

View File

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

View File

@@ -7,7 +7,10 @@ using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Controllers
@@ -71,10 +74,13 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
{
var user = await gameboardManager.ReadUser(User);
var session = await gameboardRepository.ReadSession(gameName);
if (session == null || user == null || (session.Player1 != user.Name && session.Player2 != user.Name))
if (session == null)
{
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;
@@ -92,15 +98,14 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
}
await communicationManager.BroadcastToPlayers(new MoveResponse
{
BoardState = session.Shogi.ToServiceModel(),
Game = session.ToServiceModel(),
MoveHistory = session.Shogi.MoveHistory.Select(h => h.ToServiceModel()).ToList(),
PlayerPerspective = user.Name == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2
}, session.Player1, session.Player2);
GameName = session.Name,
PlayerName = user.Id
}, session.Player1.Id, session.Player2?.Id);
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.
//[Route("")]
//public async Task<IActionResult> PostSession([FromBody] PostSession request)
@@ -125,8 +130,8 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[HttpPost]
public async Task<IActionResult> PostSession([FromBody] PostSession request)
{
var user = await gameboardManager.ReadUser(User);
var session = new Models.SessionMetadata(request.Name, request.IsPrivate, user!.Name);
var user = await ReadUserOrThrow();
var session = new Models.SessionMetadata(request.Name, request.IsPrivate, user!);
var success = await gameboardRepository.CreateSession(session);
if (success)
@@ -134,7 +139,13 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
await communicationManager.BroadcastToAll(new CreateGameResponse
{
Game = session.ToServiceModel(),
PlayerName = user.Name
PlayerName = user.Id
}).ContinueWith(cont =>
{
if (cont.Exception != null)
{
Console.Error.WriteLine("Yep");
}
});
return Ok();
}
@@ -148,29 +159,83 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[HttpGet("{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);
if (session == null)
{
return NotFound();
}
communicationManager.SubscribeToGame(session, user!.Name);
var response = new GetGameResponse()
communicationManager.SubscribeToGame(session, user!.Id);
var response = new GetSessionResponse()
{
Game = new Models.SessionMetadata(session).ToServiceModel(),
Game = new Models.SessionMetadata(session).ToServiceModel(user),
BoardState = session.Shogi.ToServiceModel(),
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);
}
[HttpGet]
public async Task<IActionResult> GetSessions()
public async Task<GetSessionsResponse> GetSessions()
{
var user = await ReadUserOrThrow();
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")]
public async Task<IActionResult> GetToken()
{
var identityId = User.UserId();
if (string.IsNullOrWhiteSpace(identityId))
{
return Unauthorized();
}
var user = await gameboardManager.ReadUser(User);
if (user == null)
{
user = new User(identityId);
var success = await gameboardRepository.CreateUser(user);
if (!success)
if (await gameboardManager.CreateUser(User))
{
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));
}
@@ -79,35 +76,28 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[AllowAnonymous]
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);
if (user != null)
// Create a guest user.
var newUser = Models.User.CreateGuestUser(Guid.NewGuid().ToString());
var success = await gameboardRepository.CreateUser(newUser);
if (!success)
{
var token = tokenCache.GenerateToken(webSessionId.ToString());
return new JsonResult(new GetGuestTokenResponse(user.Name, token));
return Conflict();
}
}
else
{
// 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(
var identity = newUser.CreateClaimsIdentity();
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
authenticationProps
);
var token = tokenCache.GenerateToken(newSessionId.ToString());
return new JsonResult(new GetGuestTokenResponse(user.Name, token));
}
user = newUser;
}
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;
}
public static string? DisplayName(this ClaimsPrincipal self)
{
return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
}
public static bool IsGuest(this ClaimsPrincipal self)
{
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
{
Task<bool> IsPlayer1(string sessionName, string playerName);
Task<bool> AssignPlayer2ToSession(string sessionName, string userName);
Task<bool> AssignPlayer2ToSession(string sessionName, User user);
Task<User?> ReadUser(ClaimsPrincipal user);
Task<bool> CreateUser(ClaimsPrincipal user);
}
public class GameboardManager : IGameboardManager
@@ -23,14 +23,25 @@ namespace Gameboard.ShogiUI.Sockets.Managers
this.repository = repository;
}
public Task<User?> ReadUser(ClaimsPrincipal user)
public Task<bool> CreateUser(ClaimsPrincipal principal)
{
var userId = user.UserId();
if (user.IsGuest() && Guid.TryParse(userId, out var webSessionId))
var id = principal.UserId();
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);
}
@@ -38,12 +49,6 @@ namespace Gameboard.ShogiUI.Sockets.Managers
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)
{
@@ -55,19 +60,15 @@ namespace Gameboard.ShogiUI.Sockets.Managers
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);
if (session != null && !session.IsPrivate && string.IsNullOrEmpty(session.Player2))
if (session != null && !session.IsPrivate && session.Player2 == null)
{
session.SetPlayer2(userName);
if (await repository.UpdateSession(session))
{
isSuccess = true;
session.SetPlayer2(user);
return await repository.UpdateSession(session);
}
}
return isSuccess;
return false;
}
}
}

View File

@@ -3,6 +3,7 @@ using Gameboard.ShogiUI.Sockets.Models;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.WebSockets;
@@ -106,9 +107,32 @@ namespace Gameboard.ShogiUI.Sockets.Managers
foreach (var kvp in connections)
{
var socket = kvp.Value;
try
{
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)

View File

@@ -10,13 +10,14 @@ namespace Gameboard.ShogiUI.Sockets.Models
// TODO: Separate subscriptions to the Session from the Session.
[JsonIgnore] public ConcurrentDictionary<string, WebSocket> Subscriptions { get; }
public string Name { get; }
public string Player1 { get; }
public string? Player2 { get; private set; }
public User Player1 { get; }
public User? Player2 { get; private set; }
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 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>();
@@ -27,11 +28,11 @@ namespace Gameboard.ShogiUI.Sockets.Models
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 string Name { get; }
public string Player1 { get; }
public string? Player2 { get; private set; }
public User Player1 { get; }
public User? Player2 { get; private set; }
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;
IsPrivate = isPrivate;
@@ -25,11 +25,27 @@
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 System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Security.Claims;
namespace Gameboard.ShogiUI.Sockets.Models
{
public class User
{
public string Name { get; }
public Guid? WebSessionId { get; }
public bool IsGuest => WebSessionId.HasValue;
public User(string name)
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"
});
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>
/// Constructor for guest user.
/// </summary>
public User(string name, Guid webSessionId)
public string Id { get; }
public string DisplayName { get; }
public WhichLoginPlatform LoginPlatform { get; }
public bool IsGuest => LoginPlatform == WhichLoginPlatform.Guest;
public User(string id, string displayName, WhichLoginPlatform platform)
{
Name = name;
WebSessionId = webSessionId;
Id = id;
DisplayName = displayName;
LoginPlatform = platform;
}
public ClaimsIdentity CreateMsalUserIdentity()
public User(UserDocument document)
{
var claims = new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, Name),
new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers.
};
return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
Id = document.Id;
DisplayName = document.DisplayName;
LoginPlatform = document.Platform;
}
public ClaimsIdentity CreateGuestUserIdentity()
public ClaimsIdentity CreateClaimsIdentity()
{
// TODO: Make this method static and factory-like.
if (!WebSessionId.HasValue)
if (LoginPlatform == WhichLoginPlatform.Guest)
{
throw new InvalidOperationException("Cannot create guest identity without a session identifier.");
}
var claims = new List<Claim>()
var claims = new List<Claim>(4)
{
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, "Shogi") // The Shogi role grants access to api controllers.
};
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
{
Unknown,
Microsoft,
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.Collections.Generic;
using System.Linq;

View File

@@ -6,6 +6,7 @@ namespace Gameboard.ShogiUI.Sockets.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; }

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 string Name { get; set; }
public string Player1 { get; set; }
public string? Player2 { get; set; }
public string Player1Id { get; set; }
public string? Player2Id { get; set; }
public bool IsPrivate { get; set; }
public IList<BoardStateDocument> History { get; set; }
@@ -16,8 +16,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
public SessionDocument() : base(WhichDocumentType.Session)
{
Name = string.Empty;
Player1 = string.Empty;
Player2 = string.Empty;
Player1Id = string.Empty;
Player2Id = string.Empty;
History = new List<BoardStateDocument>(0);
}
@@ -25,8 +25,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
: base(session.Name, WhichDocumentType.Session)
{
Name = session.Name;
Player1 = session.Player1;
Player2 = session.Player2;
Player1Id = session.Player1.Id;
Player2Id = session.Player2?.Id;
IsPrivate = session.IsPrivate;
History = new List<BoardStateDocument>(0);
}
@@ -35,14 +35,10 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
: base(sessionMetaData.Name, WhichDocumentType.Session)
{
Name = sessionMetaData.Name;
Player1 = sessionMetaData.Player1;
Player2 = sessionMetaData.Player2;
Player1Id = sessionMetaData.Player1.Id;
Player2Id = sessionMetaData.Player2?.Id;
IsPrivate = sessionMetaData.IsPrivate;
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 System;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{
public class UserDocument : CouchDocument
{
public string Name { get; set; }
public string DisplayName { 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>
/// Constructor for JSON deserializing.
/// </summary>
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;
WebSessionId = webSessionId;
Platform = WebSessionId.HasValue
? WhichLoginPlatform.Guest
: WhichLoginPlatform.Microsoft;
DisplayName = displayName;
Platform = platform;
}
}
}

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 Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Text;
@@ -16,10 +20,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
Task<bool> CreateBoardState(Models.Session session);
Task<bool> CreateSession(Models.SessionMetadata session);
Task<bool> CreateUser(Models.User user);
Task<IList<Models.SessionMetadata>> ReadSessionMetadatas();
Task<Models.User?> ReadGuestUser(Guid webSessionId);
Task<Collection<Models.SessionMetadata>> ReadSessionMetadatas();
Task<Models.Session?> ReadSession(string name);
Task<Models.Shogi?> ReadShogi(string name);
Task<bool> UpdateSession(Models.SessionMetadata session);
Task<Models.SessionMetadata?> ReadSessionMetaData(string name);
Task<Models.User?> ReadUser(string userName);
@@ -27,6 +29,15 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
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 readonly HttpClient client;
private readonly ILogger<GameboardRepository> logger;
@@ -37,87 +48,124 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
this.logger = logger;
}
public async Task<IList<Models.SessionMetadata>> ReadSessionMetadatas()
public async Task<Collection<Models.SessionMetadata>> ReadSessionMetadatas()
{
var selector = new Dictionary<string, object>(2)
{
[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 queryParams = new QueryBuilder { { "include_docs", "true" } }.ToQueryString();
var response = await client.GetAsync($"{View_SessionMetadata}{queryParams}");
var responseContent = await response.Content.ReadAsStringAsync();
var results = JsonConvert.DeserializeObject<CouchFindResult<SessionDocument>>(responseContent);
if (results != null)
var result = JsonConvert.DeserializeObject<CouchViewResult<JObject>>(responseContent);
if (result != null)
{
return results
.docs
.Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2))
.ToList();
var groupedBySession = result.rows.GroupBy(row => row.id);
var sessions = new List<Models.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 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)
{
var readShogiTask = ReadShogi(name);
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)
var queryParams = new QueryBuilder
{
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)
{
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 couchModel = JsonConvert.DeserializeObject<SessionDocument>(responseContent);
return couchModel.ToDomainModel();
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 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;
}
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>
/// 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)
{
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 response = await client.PutAsync(couchModel.Id, content);
return response.IsSuccessStatusCode;
@@ -205,66 +262,31 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
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);
var uri = new Uri(client.BaseAddress!, HttpUtility.UrlEncode(document.Id));
var response = await client.GetAsync(HttpUtility.UrlEncode(document.Id));
var response2 = await client.GetAsync(uri);
{ "include_docs", "true" },
{ "key", JsonConvert.SerializeObject(id) },
}.ToQueryString();
var response = await client.GetAsync($"{View_User}{queryParams}");
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;
}
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 response = await client.PostAsync(string.Empty, content);
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 Gameboard.ShogiUI.Sockets.Controllers;
using Gameboard.ShogiUI.Sockets.Extensions;
using Gameboard.ShogiUI.Sockets.Managers;
using Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers;
@@ -34,7 +33,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
private readonly IGameboardManager gameboardManager;
private readonly ISocketTokenCache tokenManager;
private readonly IJoinByCodeHandler joinByCodeHandler;
private readonly IJoinGameHandler joinGameHandler;
private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator;
private readonly IValidator<JoinGameRequest> joinGameRequestValidator;
@@ -45,7 +43,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
IGameboardManager gameboardManager,
ISocketTokenCache tokenManager,
IJoinByCodeHandler joinByCodeHandler,
IJoinGameHandler joinGameHandler,
IValidator<JoinByCodeRequest> joinByCodeRequestValidator,
IValidator<JoinGameRequest> joinGameRequestValidator
) : base()
@@ -56,34 +53,25 @@ namespace Gameboard.ShogiUI.Sockets.Services
this.gameboardManager = gameboardManager;
this.tokenManager = tokenManager;
this.joinByCodeHandler = joinByCodeHandler;
this.joinGameHandler = joinGameHandler;
this.joinByCodeRequestValidator = joinByCodeRequestValidator;
this.joinGameRequestValidator = joinGameRequestValidator;
}
public async Task HandleSocketRequest(HttpContext context)
{
string? userName = null;
var user = await gameboardManager.ReadUser(context.User);
if (user?.WebSessionId != null)
if (!context.Request.Query.Keys.Contains("token"))
{
// Guest account
userName = tokenManager.GetUsername(user.WebSessionId.Value);
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
else if (context.Request.Query.Keys.Contains("token"))
{
// Microsoft account
var token = Guid.Parse(context.Request.Query["token"][0]);
userName = tokenManager.GetUsername(token);
}
var userName = tokenManager.GetUsername(token);
if (string.IsNullOrEmpty(userName))
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
else
{
var socket = await context.WebSockets.AcceptWebSocketAsync();
communicationManager.SubscribeToBroadcast(socket, userName);
@@ -95,31 +83,25 @@ namespace Gameboard.ShogiUI.Sockets.Services
if (string.IsNullOrWhiteSpace(message)) continue;
logger.LogInformation("Request \n{0}\n", 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.");
continue;
}
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:
{
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);
}
break;
}
default:
await socket.SendTextAsync($"Received your message with action {request.Action}, but did no work.");
break;
}
}
catch (OperationCanceledException ex)
@@ -132,9 +114,7 @@ namespace Gameboard.ShogiUI.Sockets.Services
logger.LogInformation("Probably tried writing to a closed socket.");
logger.LogError(ex.Message);
}
}
communicationManager.UnsubscribeFromBroadcastAndGames(userName);
return;
}
}
@@ -144,11 +124,7 @@ namespace Gameboard.ShogiUI.Sockets.Services
if (!results.IsValid)
{
var errors = string.Join('\n', results.Errors.Select(_ => _.ErrorMessage));
var message = JsonConvert.SerializeObject(new Response
{
Error = errors
});
await socket.SendTextAsync(message);
await socket.SendTextAsync(errors);
}
return results.IsValid;
}

View File

@@ -5,6 +5,5 @@ namespace Gameboard.ShogiUI.Sockets.Services.Utility
public class Response : IResponse
{
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);
if (user == null)
{
var newUser = new Models.User(nameClaim.Value);
var newUser = Models.User.CreateMsalUser(nameClaim.Value);
var success = await gameboardRepository.CreateUser(newUser);
if (success) user = newUser;
}
if (user != null)
{
return new ClaimsPrincipal(user.CreateMsalUserIdentity());
return new ClaimsPrincipal(user.CreateClaimsIdentity());
}
}
return principal;

View File

@@ -13,7 +13,9 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Http;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Newtonsoft.Json;
@@ -41,7 +43,6 @@ namespace Gameboard.ShogiUI.Sockets
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>();
services.AddSingleton<IJoinGameHandler, JoinGameHandler>();
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<IGameboardManager, GameboardManager>();
@@ -109,6 +110,9 @@ namespace 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.

View File

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