Compare commits

...

6 Commits

Author SHA1 Message Date
efim b29d1a1ef1 new auth method for joining room 2023-04-25 12:17:37 +04:00
efim 1b71c942ec adding room service, joining room
adding nick password into model, stored in the Room
to allow more secure joining for repeated times
2023-04-25 11:41:32 +04:00
efim 2244f38348 scalafmt all 2023-04-25 10:25:36 +04:00
efim 0a721b135f stubbed auth with tagless final 2023-04-25 10:23:13 +04:00
efim 64267a5f67 backend - adding room service and wiring
preparing to create a better auth module that uses room service
2023-04-25 09:55:45 +04:00
efim dfff814079 cleanup 2023-04-25 09:45:21 +04:00
11 changed files with 382 additions and 186 deletions

View File

@ -1,2 +1,3 @@
runner.dialect = scala3
version = 3.7.3
maxColumn = 100

View File

@ -1,74 +1,115 @@
package industries.sunshine.planningpoker
import cats.effect._
import cats.syntax.all._
import cats.data.Kleisli
import cats.data.OptionT
import org.http4s.Request
import industries.sunshine.planningpoker.common.Models.PlayerID
import java.util.UUID
import industries.sunshine.planningpoker.common.Models.RoomID
import industries.sunshine.planningpoker.common.Models.{RoomID, Room}
import org.http4s.ResponseCookie
import cats.data.EitherT
import scala.util.Random
trait Auth {
trait Auth[F[_]] {
/** for middleware that converts sessionId into PlayerId
*/
def authUser: Kleisli[[A] =>> cats.data.OptionT[cats.effect.IO, A], Request[
cats.effect.IO
], (PlayerID, RoomID)]
def authUser: Kleisli[[A] =>> cats.data.OptionT[F, A], Request[F], (PlayerID, RoomID)]
/** Get sessionId for accessing a room
* @param roomPassword
* \- requirement to get access to the room
*
* check that room exists, password is valid call to add user to the players create session
* mapping and return cookie
*/
def accessRoom(
def joinRoom(
roomName: String,
roomPassword: String,
nickName: String
): IO[Either[Unit, Long]]
nickName: String,
nickPassword: String
): F[Either[String, ResponseCookie]]
def leaveRoom(
sessionId: Long
): IO[Unit]
def deleteSession(
sessionId: Long
): F[Unit]
}
object Auth {
def pureBadStub = new Auth {
def pureBadStub = new Auth[IO] {
override def authUser =
Kleisli((req: Request[IO]) =>
OptionT.liftF(IO(println(s"authUser: $req")) >> IO(PlayerID(14) -> RoomID(101)))
OptionT.liftF(
IO(println(s"authUser: $req")) >> IO(
PlayerID(14) -> RoomID("testroom")
)
)
)
override def accessRoom(
override def joinRoom(
roomName: String,
roomPassword: String,
nickName: String
nickName: String,
nickPassword: String
) =
IO(println(s"> access room for $roomName $roomPassword $nickName, to return stub 111")) >> IO.pure(Right(111L))
IO(
println(
s"> access room for $roomName $roomPassword $nickName, to return stub 111"
)
) >> IO.pure(Right(ResponseCookie("authcookie", "1")))
override def leaveRoom(sessionId: Long): IO[Unit] =
override def deleteSession(sessionId: Long): IO[Unit] =
IO(s"got request to leave for $sessionId")
}
type SessionsMap = Map[Long, (RoomID, PlayerID)]
val sessionsRef = Ref.of[IO, SessionsMap](Map.empty)
val roomPasswordsRef = Ref.of[IO, Map[Long, String]](Map.empty)
def apply(): IO[Auth] =
for {
store <- sessionsRef
roomPasswords <- roomPasswordsRef
} yield new Auth {
override def authUser = Kleisli { (req: Request[IO]) =>
{
???
}
}
override def accessRoom(
roomName: String,
roomPassword: String,
nickName: String
): IO[Either[Unit, Long]] = ???
class SimpleAuth[F[_]: Sync](
sessions: Ref[F, SessionsMap],
roomService: RoomService[F]
) extends Auth[F] {
override def leaveRoom(sessionId: Long): IO[Unit] = ???
val authcookie = "authcookie"
override def joinRoom(
roomName: String,
roomPassword: String,
nickName: String,
nickPassword: String
): F[Either[String, ResponseCookie]] = {
// TODO check for existing session for same room
// and do i want to logout if existing session for another room? ugh
val roomId = RoomID(roomName)
val result = for {
playerId <- EitherT(
roomService.joinRoom(roomId, nickName, nickPassword, roomPassword)
)
.leftMap(_.toString())
newSessionId = Random.nextLong()
_ <- EitherT.liftF(sessions.update(_.updated(newSessionId, (roomId, playerId))))
} yield ResponseCookie(name = authcookie, content = newSessionId.toString(), secure = true)
result.value
}
override def authUser
: Kleisli[[A] =>> cats.data.OptionT[F, A], Request[F], (PlayerID, RoomID)] = {
// check authcookie presence, exchange it for playerID ad roomID
???
}
override def deleteSession(sessionId: Long): F[Unit] = {
// i suppose leaving the room should just be authed route & method
???
}
}
def make[F[_]: Sync](roomsService: RoomService[F]): F[Auth[F]] =
for {
sessionsMap <- Ref.of[F, SessionsMap](
Map(1L -> (RoomID("testroom"), PlayerID(1L)))
)
} yield new SimpleAuth(sessionsMap, roomsService)
}

View File

@ -12,17 +12,21 @@ object BackendApp extends IOApp {
val host = host"0.0.0.0"
val port = port"8080"
val server = for {
srv <- EmberServerBuilder
val wiring = for {
roomService <- Resource.eval(RoomService.make[IO])
httpService = MyHttpService.create(Auth.pureBadStub)
server <- EmberServerBuilder
.default[IO]
.withHost(host)
.withPort(port)
.withHttpWebSocketApp(MyHttpService.create(Auth.pureBadStub)(_))
.withHttpWebSocketApp(httpService(_))
.build
} yield srv
} yield server
server.use(server =>
IO.delay(println(s"Server Has Started at ${server.address}")) >> IO.never.as(ExitCode.Success))
wiring.use(server =>
IO.delay(println(s"Server Has Started at ${server.address}")) >> IO.never
.as(ExitCode.Success)
)
}
}

View File

@ -16,7 +16,7 @@ import org.http4s.server.AuthMiddleware.apply
import org.http4s.server.AuthMiddleware
object MyHttpService {
def create(auth: Auth)(
def create(auth: Auth[IO])(
wsb: WebSocketBuilder[cats.effect.IO]
): HttpApp[cats.effect.IO] = {
@ -24,9 +24,11 @@ object MyHttpService {
AuthedRoutes.of {
case GET -> Root / "subscribe" as (playerId, roomId) => {
val send: Stream[IO, WebSocketFrame] =
Stream
Stream
.emits(TestModels.testChangesList)
.covary[IO].metered(1.second).map(state => WebSocketFrame.Text(state.asJson.noSpaces))
.covary[IO]
.metered(1.second)
.map(state => WebSocketFrame.Text(state.asJson.noSpaces))
val receive: Pipe[IO, WebSocketFrame, Unit] = _.evalMap {
case WebSocketFrame.Text(text, _) => Sync[IO].delay(println(text))
@ -54,20 +56,17 @@ object MyHttpService {
case req @ POST -> Root / "login" => {
for {
data <- req.as[Requests.LogIn]
authResult <- auth.accessRoom(
authResult <- auth.joinRoom(
data.roomName,
data.password,
data.nickname
)
data.nickname,
data.nickPassword)
resp <- authResult match {
case Left(error) =>
Forbidden(error)
case Right(sessionId) => {
Ok("Logged in!").map(
_.addCookie(
ResponseCookie("authcookie", sessionId.toString())
)
)
case Right(authCookie) => {
IO(println(s"> logging in ${data.nickname} to ${data.roomName}")) >>
Ok().map(_.addCookie(authCookie))
}
}
} yield resp

View File

@ -0,0 +1,96 @@
package industries.sunshine.planningpoker
import industries.sunshine.planningpoker.common.Models.*
import cats.effect.{Ref, Sync}
import cats.syntax.all._
import cats.data.EitherT
enum RoomError {
case RoomAlreadyExists(name: String)
case RoomMissing(name: String)
case RoomPassIncorrect
case NickPassIncorrect
}
trait RoomService[F[_]] {
def createRoom(newRoom: Room): F[Either[RoomError, Room]]
def updateRoom(room: Room): F[Unit]
def joinRoom(
id: RoomID,
nickName: String,
nickPassword: String,
roomPassword: String
): F[Either[RoomError, PlayerID]]
def deleteRoom(roomID: RoomID): F[Unit]
def getRoom(roomID: RoomID): F[Option[Room]]
}
class InMemoryRoomService[F[_]: Sync](stateRef: Ref[F, Map[RoomID, Room]]) extends RoomService[F] {
override def createRoom(newRoom: Room): F[Either[RoomError, Room]] = {
stateRef.modify { rooms =>
rooms.get(newRoom.id) match {
case Some(_) =>
rooms -> RoomError.RoomAlreadyExists(newRoom.id.name).asLeft[Room]
case None =>
(rooms.updated(newRoom.id, newRoom)) -> newRoom.asRight[RoomError]
}
}
}
override def updateRoom(room: Room): F[Unit] = stateRef.update { state =>
state.get(room.id).fold(state)(oldRoom => state.updated(room.id, room))
}
override def deleteRoom(roomID: RoomID): F[Unit] = stateRef.update(_.removed(roomID))
override def getRoom(roomID: RoomID): F[Option[Room]] = stateRef.get.map(_.get(roomID))
override def joinRoom(
id: RoomID,
nickName: String,
nickPassword: String,
roomPassword: String
): F[Either[RoomError, PlayerID]] = stateRef.modify { rooms =>
// need to cover cases:
// - player already present, then return as is, i guess
// - nick not known - add new player and new nick-password mapping
// - nick known - add new player
def addPlayer(room: Room): (PlayerID, Room) = {
room.players.find(_.name == nickName) match {
case Some(player) => player.id -> room
case None => // player is not present, but potentially was previously
val addingPlayer = Player.create(nickPassword)
val roomWithPlayer = room.copy(players = addingPlayer :: room.players)
room.playersPasswords.get(nickName) match {
case Some(_) => addingPlayer.id -> roomWithPlayer
case None =>
addingPlayer.id -> roomWithPlayer.copy(playersPasswords =
roomWithPlayer.playersPasswords.updated(nickName, nickPassword)
)
}
}
}
val joiningWithChecks = for {
room <- rooms.get(id).toRight(RoomError.RoomMissing(id.name))
_ <- Either.cond(room.password == roomPassword, (), RoomError.RoomPassIncorrect)
isNickPassCorrect = room.playersPasswords
.get(nickName)
.fold(true)(existingPass => existingPass == nickPassword)
_ <- Either.cond(
isNickPassCorrect,
(),
RoomError.NickPassIncorrect
)
(playerId, updatedRoom) = addPlayer(room)
} yield playerId
rooms -> joiningWithChecks
}
}
object RoomService {
def make[F[_]: Sync]: F[RoomService[F]] = {
Ref.of[F, Map[RoomID, Room]](Map.empty).map(new InMemoryRoomService[F](_))
}
}

View File

@ -55,7 +55,7 @@ lazy val backend = project
libraryDependencies += "co.fs2" %% "fs2-core" % "3.6.1",
libraryDependencies += "org.typelevel" %% "cats-core" % "2.9.0",
libraryDependencies += "org.typelevel" %% "cats-effect" % "3.4.9",
assembly / mainClass := Some("industries.sunshine.planningpoker.BackendApp"),
assembly / mainClass := Some("industries.sunshine.planningpoker.BackendApp")
)
.dependsOn(common.jvm)
@ -76,6 +76,8 @@ lazy val commonJS = common.js.settings(
// scalaJS specific settings
scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.ESModule)
.withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("industries.sunshine.planningpoker")))
.withModuleSplitStyle(
ModuleSplitStyle.SmallModulesFor(List("industries.sunshine.planningpoker"))
)
}
)

View File

@ -2,14 +2,19 @@ package industries.sunshine.planningpoker.common
import java.util.UUID
import io.circe._
import scala.util.Random
object Models {
/** view of the single planning poker round
* @param players - people who are currently playing
* @param allowedCards- the cards values that can be used by players
* @param round - state of the selected cards of the players
* @param canCloseRound - whether current player has access to button to finish the round
* @param players
* \- people who are currently playing
* @param allowedCards-
* the cards values that can be used by players
* @param round
* \- state of the selected cards of the players
* @param canCloseRound
* \- whether current player has access to button to finish the round
*/
final case class RoomStateView(
players: List[Player],
@ -23,30 +28,76 @@ object Models {
object RoomStateView {
val empty = RoomStateView(
List.empty, PlayerID(0), List.empty, RoundState.Voting(None, List.empty), false
List.empty,
PlayerID(0),
List.empty,
RoundState.Voting(None, List.empty),
false
)
}
enum RoundState derives Codec.AsObject:
/** view state for round before votes are open player can know their vote and
* who of the other players have voted
*/
/** view state for round before votes are open player can know their vote and who of the other
* players have voted
*/
case Voting(
myCard: Option[String],
alreadyVoted: List[Player]
)
myCard: Option[String],
alreadyVoted: List[Player]
)
/** view state for round after opening the votes
*/
/** view state for round after opening the votes
*/
case Viewing(
votes: List[(PlayerID, String)]
)
votes: List[(PlayerID, String)]
)
final case class PlayerID(id: Long) derives Codec.AsObject
final case class Player(name: String, id: PlayerID) derives Codec.AsObject
object Player {
def create(name: String) = Player(name, PlayerID(Random.nextLong))
}
final case class RoomID(id: Long) derives Codec.AsObject
final case class RoomID(name: String) derives Codec.AsObject
final case class Room(
id: RoomID,
players: List[Player],
owner: PlayerID,
password: String,
allowedCards: List[String],
round: RoundState,
playersPasswords: Map[String, String] = Map.empty // nickname into password
) {
def toViewFor(playerId: PlayerID): RoomStateView = {
players
.find(_.id == playerId)
.fold(ifEmpty = RoomStateView.empty)((me: Player) =>
RoomStateView(
players,
me.id,
allowedCards,
round,
playerId == owner
)
)
}
}
object Room {
val testRoom = Room(
id = RoomID("testroom"),
players = List(
Player("me", PlayerID(1L)),
Player("horsey", PlayerID(444L)),
Player("froggy", PlayerID(555L)),
Player("owley", PlayerID(777L))
),
owner = PlayerID(1L),
password = "password",
allowedCards = List("S", "M", "L"),
// TODO - this needs to be a different hting
round = RoundState.Voting(None, List.empty)
)
}
}

View File

@ -4,5 +4,6 @@ import io.circe.generic.semiauto._
import io.circe._
object Requests {
final case class LogIn(roomName: String, nickname: String, password: String) derives Codec.AsObject
final case class LogIn(roomName: String, nickname: String, password: String, nickPassword: String)
derives Codec.AsObject
}

View File

@ -3,104 +3,104 @@ package industries.sunshine.planningpoker
import industries.sunshine.planningpoker.common.Models.*
object TestModels {
val me = Player("wormy", PlayerID(1))
val pony = Player("pony", PlayerID(10))
val birdy = Player("birdy", PlayerID(11))
val horsey = Player("horsey", PlayerID(12))
val me = Player("wormy", PlayerID(1))
val pony = Player("pony", PlayerID(10))
val birdy = Player("birdy", PlayerID(11))
val horsey = Player("horsey", PlayerID(12))
val testRoom = {
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("s"), alreadyVoted = List(me, pony)),
canCloseRound = true
)
}
val testOpenedRoom = {
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
List(me.id -> "xs", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
canCloseRound = true
)
}
val testChangesList = List(
RoomStateView(
players = List(me),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey, pony)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
List(me.id -> "m", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
canCloseRound = true
)
val testRoom = {
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("s"), alreadyVoted = List(me, pony)),
canCloseRound = true
)
}
val testOpenedRoom = {
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
List(me.id -> "xs", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
canCloseRound = true
)
}
val testChangesList = List(
RoomStateView(
players = List(me),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List.empty),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = None, alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Voting(myCard = Some("m"), alreadyVoted = List(birdy, horsey, pony)),
canCloseRound = true
),
RoomStateView(
players = List(me, birdy, pony, horsey),
me = me.id,
allowedCards = List("xs", "s", "m", "l", "xl"),
round = RoundState.Viewing(
List(me.id -> "m", pony.id -> "l", birdy.id -> "s", horsey.id -> "m")
),
canCloseRound = true
)
)
}

View File

@ -13,7 +13,7 @@ import org.scalajs.dom
val javascriptLogo: String = js.native
@main
def PlanningPokerUrgh(): Unit =
def FrontendMain(): Unit =
renderOnDomContentLoaded(
dom.document.getElementById("app"),
Main.appElement()
@ -30,7 +30,9 @@ object Main {
import io.laminext.websocket.circe.WebSocket._
import io.laminext.websocket.circe.webSocketReceiveBuilderSyntax
val roomStateWSStream = io.laminext.websocket.WebSocket.path("/api/subscribe").json[RoomStateView, Unit]
val roomStateWSStream = io.laminext.websocket.WebSocket
.path("/api/subscribe")
.json[RoomStateView, Unit]
.build(
managed = true,
autoReconnect = false,

View File

@ -13,17 +13,16 @@ object TableView {
// so, it's more efficient to share an observable, than to create multiple copies...
def renderTable(roundSignal: Signal[RoomStateView]): Element = {
val playerIdToCardTypeSignal =
roundSignal.combineWith(Main.appStateSignal.map(_.myId)).map((state, myIdOpt) =>
state.players.map(p =>
p.id -> getPlayerCardType(p.id, state.round, p.name, myIdOpt)
roundSignal
.combineWith(Main.appStateSignal.map(_.myId))
.map((state, myIdOpt) =>
state.players.map(p => p.id -> getPlayerCardType(p.id, state.round, p.name, myIdOpt))
)
)
div(
className := "w-full h-full border-2 border-amber-700 flex flex-row justify-center items-center bg-green-100",
children <-- playerIdToCardTypeSignal.split(_._1) {
(id, initial, cardTypeSignal) =>
renderPlayerCard(cardTypeSignal.map(_._2))
children <-- playerIdToCardTypeSignal.split(_._1) { (id, initial, cardTypeSignal) =>
renderPlayerCard(cardTypeSignal.map(_._2))
}
)
}
@ -57,8 +56,8 @@ object TableView {
def renderPlayerCard(state: Signal[CardState]): Element = {
val cardTypeStyle = state.map {
case NoCard(_) => "bg-green-100 text-black border-2 border-black"
case CardBack => "bg-green-500 border-4 border-green-700"
case Open(_) => "text-black bg-gray-50 border-black border-2"
case CardBack => "bg-green-500 border-4 border-green-700"
case Open(_) => "text-black bg-gray-50 border-black border-2"
}
div(
@ -67,11 +66,11 @@ object TableView {
div(
className := "-rotate-45 text-xl",
child.text <-- state.map {
case NoCard(name) => name
case CardBack => ""
case Open(vote) => vote
case NoCard(name) => name
case CardBack => ""
case Open(vote) => vote
}
),
)
)
}