Compare commits

..
Author SHA1 Message Date
artemis e9d1f93723 Auctions show 2026-09-18 19:38:38 +03:00
artemis d1360b2917 Some ETag refactoring 2026-09-18 19:36:53 +03:00
artemis e144a7f3c0 Refactored checkFresh 2026-09-17 15:08:38 +03:00
artemis 621fcf90fa Caching and user data endpoint 2026-09-16 23:56:40 +03:00
artemis c45889838b Replaced Ints with Longs as database Ids. 2026-09-16 16:22:00 +03:00
artemis 98fa797a05 Added Artemis to compose.yaml 2026-09-13 00:37:40 +03:00
artemis 8c0deee2df Small refactoring of the sessions controller 2026-09-13 00:37:27 +03:00
artemis 2754c86c63 Refactored Auctions index 2026-09-09 22:42:01 +03:00
artemis eadf793882 Auctions index and CORS filter 2026-09-03 00:02:34 +03:00
artemis f2ec6889cb Dev actions controller/router + user registration 2026-09-02 00:40:09 +03:00
artemis a6bbef1bf3 Reworked AuthenticatedAction 2026-09-01 00:42:38 +03:00
artemis 88b187e6a3 Auction status change and confirmation 2026-08-27 20:16:43 +03:00
artemis cab27eddab Sending actor data to MQTT 2026-08-27 15:28:02 +03:00
artemis 782ab79c42 CSRF and authentication mild rework 2026-08-26 15:18:04 +03:00
artemis 81e126013b Auctions schema and creation 2026-08-25 23:38:29 +03:00
artemis 8582a92d85 Added User to AuthenticatedAction 2026-08-25 23:37:40 +03:00
artemis 9bfee2978e Added MQTT actor 2026-07-19 01:45:49 +03:00
26 changed files with 708 additions and 70 deletions
-1
View File
@@ -1 +0,0 @@
{"name":"sbt","version":"1.12.13","bspVersion":"2.1.0-M1","languages":["scala"],"argv":["/usr/lib/jvm/java-21-openjdk-amd64/bin/java","-Xms100m","-Xmx100m","-classpath","/home/artemiy/.local/share/JetBrains/IntelliJIdea2026.1/Scala/launcher/sbt-launch.jar","-Dsbt.script=/home/artemiy/.local/bin/sbt","xsbt.boot.Boot","-bsp"]}
+2
View File
@@ -8,3 +8,5 @@ hs_err_pid*
/project/project/target/ /project/project/target/
/project/target /project/target
/target /target
.bsp
+103
View File
@@ -0,0 +1,103 @@
package actors.auctions
import com.google.inject.Provides
import com.hivemq.client.mqtt.datatypes.MqttQos
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient
import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish
import dao.AuctionsDAO
import models.{Auction, AuctionStatus, User}
import mqttClient.MqttClientFactory
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.{Configuration, Logging}
import play.api.libs.concurrent.ActorModule
import play.api.libs.json.{JsBoolean, JsObject, JsValue, Json}
import java.nio.charset.StandardCharsets
import javax.inject.Singleton
import scala.jdk.OptionConverters.RichOptional
import scala.util.Random
object AuctionPublisher extends ActorModule with Logging {
sealed trait Command
private case object Ready extends Command
case class Publish(auction: Auction, author: User) extends Command
private case class AuctionConfirmed(id: Long, responseTopic: String) extends Command
private final val topic = "auctions/created"
override type Message = Command
@Provides
@Singleton
def apply(configuration: Configuration, auctionsDAO: AuctionsDAO): Behavior[Message] = Behaviors.setup { context =>
val client = MqttClientFactory.createClient(configuration, "-auction-publisher")
client.connect().whenComplete { (_action, _throwable) =>
context.self ! Ready
}
idle(context.self, client, auctionsDAO)
}
private def idle(self: ActorRef[Message], client: Mqtt5AsyncClient, auctionsDAO: AuctionsDAO, waitingPublishes: Seq[Publish] = List()): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case p: Publish =>
idle(self, client, auctionsDAO, waitingPublishes :+ p)
case Ready =>
for { publish <- waitingPublishes } publishAuction(client, publish.auction, publish.author, self)
ready(self, client, auctionsDAO)
case message @ AuctionConfirmed(_, _) =>
logger.warn(s"Received impossible message $message")
idle(self, client, auctionsDAO, waitingPublishes)
}
private def ready(self: ActorRef[Message], client: Mqtt5AsyncClient, auctionsDAO: AuctionsDAO): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Publish(auction, author) =>
publishAuction(client, auction, author, self)
ready(self, client, auctionsDAO)
case AuctionConfirmed(auctionId, responseTopic) =>
client.unsubscribeWith().topicFilter(responseTopic).send()
auctionsDAO.changeStatus(auctionId, AuctionStatus.Ready)
ready(self, client, auctionsDAO)
case Ready =>
logger.warn("Message Ready received after having already received it before")
ready(self, client, auctionsDAO)
}
private def publishAuction(client: Mqtt5AsyncClient, auction: Auction, author: User, self: ActorRef[Command]): Unit = {
val payload = Json.toBytes(auction_payload(auction, author))
val responseTopic = s"auction-validate/${auction.gid}-${Random.alphanumeric.take(8).mkString}"
client.subscribeWith()
.topicFilter(responseTopic)
.callback{ message =>
logger.info(s"Receiving message on ${message.getTopic}")
logger.info(message.getPayloadAsBytes.mkString("Array(", ", ", ")"))
message.getPayload.toScala
.map(StandardCharsets.UTF_8.decode(_).toString)
.map(Json.parse)
.flatMap(v => (v \ "ok").toOption)
.map{
case b: JsBoolean if b.value => self ! AuctionConfirmed(auction.id.get, responseTopic)
// TODO: add error handling
}
}
.send()
val publish = Mqtt5Publish.builder()
.topic(topic)
.payload(payload)
.responseTopic(responseTopic)
.qos(MqttQos.EXACTLY_ONCE)
.build()
logger.info(s"Publishing auction ${auction.id.getOrElse(-1)}")
client.publish(publish)
}
private def auction_payload(auction: Auction, author: User): JsObject = {
Json.obj(
"gid" -> auction.gid,
"author_gid" -> author.gid,
"starting_price" -> auction.startingPrice
)
}
}
+73
View File
@@ -0,0 +1,73 @@
package controllers
import actors.auctions.AuctionPublisher
import controllers.authentication.AuthenticatedAction
import dao.{AuctionsDAO, AuthTokensDAO}
import org.apache.pekko.actor.typed.ActorRef
import play.api.libs.json.*
import play.api.mvc.{BaseController, ControllerComponents}
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
import models.Auction
import caching.{CheckFresh, DigestEtagable, Etagable}
import java.io.DataOutputStream
@Singleton
class AuctionsController @Inject()(val controllerComponents: ControllerComponents,
val auctionsDAO: AuctionsDAO,
val authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction,
val auctionPublisher: ActorRef[AuctionPublisher.Command])
(implicit val ec: ExecutionContext)
extends BaseController with CheckFresh {
def index = authenticatedAction.async { request =>
for {
auctions <- auctionsDAO.list_available(request.user)
json = Json.toJson(auctions)
} yield Ok(json)
}
def show(id: Long) = authenticatedAction.async { implicit request =>
auctionsDAO.showAvailable(request.user, id).map { auctionOpt =>
auctionOpt.fold(NotFound) { auction =>
checkFresh(auction) {
Ok(Json.toJson(auction)).withHeaders("etag" -> auction.etag)
}
}
}
}
def create = authenticatedAction.async(parse.json) { implicit request =>
val auctionCreateDataResult = request.body.validate[AuctionCreateData]
auctionCreateDataResult.fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
auctionCreateData => {
for {
auction <- auctionsDAO.create(auctionCreateData.name, auctionCreateData.startingPrice, request.user)
json = Json.toJson(auction)
_ = auctionPublisher ! AuctionPublisher.Publish(auction, request.user)
} yield Created(json)
}
)
}
private case class AuctionCreateData(name: String, startingPrice: BigDecimal)
private given Reads[AuctionCreateData] = Json.reads[AuctionCreateData]
private given DigestEtagable[Auction] with {
override protected def writeDigestData(a: Auction, s: DataOutputStream): Unit = {
s.writeLong(a.id.getOrElse(0))
s.writeLong(a.authorId)
s.writeUTF(a.gid)
s.writeUTF(a.name)
s.writeLong(a.createdAt.getEpochSecond)
s.writeLong(a.currentBidderId.getOrElse(0))
a.currentBid.foreach(b => s.writeLong((b * 1000).toLong))
a.currentBidAt.foreach(ba => s.writeLong(ba.getEpochSecond))
}
}
}
+29 -4
View File
@@ -1,15 +1,24 @@
package controllers package controllers
import javax.inject._ import play.api.*
import play.api._ import _root_.controllers.authentication.{AuthenticatedAction, AuthenticatedRequest}
import play.api.mvc._ import _root_.controllers.caching.{CheckFresh, DigestEtagable}
import models.User
import play.api.libs.json.Json
import play.api.mvc.*
import java.io.DataOutputStream
import java.nio.charset.StandardCharsets.UTF_8
import javax.inject.*
/** /**
* This controller creates an `Action` to handle HTTP requests to the * This controller creates an `Action` to handle HTTP requests to the
* application's home page. * application's home page.
*/ */
@Singleton @Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { class HomeController @Inject()(val controllerComponents: ControllerComponents,
authenticatedAction: AuthenticatedAction)
extends BaseController with Logging with CheckFresh {
/** /**
* Create an Action to render an HTML page. * Create an Action to render an HTML page.
@@ -21,4 +30,20 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e
def index() = Action { implicit request: Request[AnyContent] => def index() = Action { implicit request: Request[AnyContent] =>
Ok(views.html.index()) Ok(views.html.index())
} }
def userData = authenticatedAction { implicit request: AuthenticatedRequest[AnyContent] =>
val user = request.user
checkFresh(user) {
// TODO: put the max-age somewhere into config
Ok(Json.toJson(user)).withHeaders("Cache-Control" -> "private, max-age=15, must-revalidate")
}
}
private given DigestEtagable[models.User] with {
override protected def writeDigestData(a: User, s: DataOutputStream): Unit = {
s.writeLong(a.id.getOrElse(0))
s.write(a.gid.getBytes(UTF_8))
s.write(a.username.getBytes(UTF_8))
}
}
} }
@@ -2,34 +2,39 @@ package controllers.authentication
import dao.AuthTokensDAO import dao.AuthTokensDAO
import jakarta.inject.Inject import jakarta.inject.Inject
import models.AuthToken import models.{AuthToken, User}
import play.api.libs.json.* import play.api.libs.json.*
import play.api.mvc.Results.Unauthorized import play.api.mvc.Results.Unauthorized
import play.api.mvc.{ActionRefiner, Request, Result, WrappedRequest} import play.api.mvc.{ActionBuilder, ActionRefiner, AnyContent, BodyParsers, Request, Result, WrappedRequest}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class AuthenticatedRequest[A](val token: AuthToken, request: Request[A]) extends WrappedRequest[A](request) class AuthenticatedRequest[A](val token: AuthToken, val user: User, request: Request[A]) extends WrappedRequest[A](request)
class AuthenticatedAction @Inject()(authTokensDao: AuthTokensDAO)(implicit val executionContext: ExecutionContext) extends ActionRefiner[Request, AuthenticatedRequest] {
private final val tokenPrefix = "Token"
class AuthenticatedAction @Inject()(val parser: BodyParsers.Default, authTokensDao: AuthTokensDAO)(implicit val executionContext: ExecutionContext)
extends ActionRefiner[Request, AuthenticatedRequest]
with ActionBuilder[AuthenticatedRequest, AnyContent]{
override protected def refine[A](request: Request[A]): Future[Either[Result, AuthenticatedRequest[A]]] = { override protected def refine[A](request: Request[A]): Future[Either[Result, AuthenticatedRequest[A]]] = {
val unauthenticatedRequestError = Left(Unauthorized(Json.obj("error" -> "Unauthenticated")))
val authHeader = request.headers.get("Authorization") val authHeader = request.headers.get("Authorization")
.flatMap { header => .map(_.split(" ", 2)(1))
if header.startsWith(tokenPrefix) then Some(header.substring(tokenPrefix.length).trim)
else None
}
.filter(_.nonEmpty) .filter(_.nonEmpty)
authHeader match { authHeader match {
case Some(token) => case Some(token) =>
authTokensDao.find(token).map { authTokensDao.find(token).flatMap {
case Some(authToken) => Right(AuthenticatedRequest[A](authToken, request)) case Some(authToken) => findUser(request, authToken)
case None => unauthenticatedRequestError case None => Future.successful(unauthenticatedRequestError)
} }
case None => Future.successful(unauthenticatedRequestError) case None => Future.successful(unauthenticatedRequestError)
} }
} }
private val unauthenticatedRequestError = Left(Unauthorized(Json.obj("error" -> "Unauthenticated")))
private def findUser[A](request: Request[A], token: AuthToken) = {
authTokensDao.getUser(token).map {
case Some(user) => Right(AuthenticatedRequest(token, user, request))
case None => unauthenticatedRequestError
}
}
} }
@@ -5,46 +5,49 @@ import jakarta.inject.{Inject, Singleton}
import models.AuthToken import models.AuthToken
import play.api.libs.json.* import play.api.libs.json.*
import play.api.libs.functional.syntax.* import play.api.libs.functional.syntax.*
import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents} import play.api.mvc.{AbstractController, ControllerComponents}
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
case class LoginData(username: String, password: String) object SessionsController {
implicit val loginDataReads: Reads[LoginData] = ( case class LoginData(username: String, password: String)
(JsPath \ "username").read[String] and given Reads[LoginData] = Json.reads[LoginData]
(JsPath \ "password").read[String] }
)(LoginData.apply)
implicit val tokenWrites: Writes[AuthToken] = (token: AuthToken) => Json.obj(
"token" -> token.token,
"createdAt" -> token.createdAt,
"expiresAt" -> token.expiresAt
)
@Singleton @Singleton
class SessionsController @Inject()(controllerComponents: ControllerComponents, class SessionsController @Inject()(
usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO, controllerComponents: ControllerComponents,
authenticatedAction: AuthenticatedAction, usersDAO: UsersDAO,
implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) { authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction)
(implicit val ec: ExecutionContext)
extends AbstractController(controllerComponents) {
import SessionsController.{LoginData, given}
def login() = Action.async(parse.json) { implicit request => def login() = Action.async(parse.json) { implicit request =>
val loginDataResult = request.body.validate[LoginData] val loginDataResult = request.body.validate[LoginData]
loginDataResult.fold( loginDataResult.fold(
errors => { errors => Future.successful(BadRequest(JsError.toJson(errors))),
Future.successful(BadRequest(Json.obj("message" -> JsError.toJson(errors))))
},
loginData => { loginData => {
usersDAO.authenticate(loginData.username, loginData.password).flatMap { userOption => usersDAO.authenticate(loginData.username, loginData.password).flatMap { userOption =>
userOption.fold( userOption.fold(
Future.successful(UnprocessableEntity(Json.obj("message" -> "Invalid authentication"))) Future.successful(UnprocessableEntity(Json.obj("message" -> "Invalid authentication")))
){ user => ){ user =>
authTokensDAO.createToken(user).map(token => Ok(Json.toJson(token))) authTokensDAO.createToken(user).map { token =>
val json = Json.toJsObject(token) ++ Json.obj(
"userId" -> user.id.get,
"userGid" -> user.gid
)
Ok(json)
}
} }
} }
} }
) )
} }
def logout = (Action andThen authenticatedAction).async { request => def logout = authenticatedAction.async { request =>
authTokensDAO.destroy(request.token).map{ _ => Ok("") } authTokensDAO.destroy(request.token).map{ _ => Ok("") }
} }
} }
+57
View File
@@ -0,0 +1,57 @@
package controllers.caching
import play.api.mvc.Results.NotModified
import play.api.mvc.{Request, Result}
import java.io.{ByteArrayOutputStream, DataOutputStream, OutputStream}
import java.security.{DigestOutputStream, MessageDigest}
import java.util.Base64
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Using
trait Etagable[A]:
extension (a: A) def etag: String
trait DigestEtagable[A] extends Etagable[A]:
private val base64 = Base64.getUrlEncoder.withoutPadding
protected def writeDigestData(a: A, s: DataOutputStream): Unit
extension (a: A) {
override def etag: String = {
val md = MessageDigest.getInstance("SHA-1")
Using(DataOutputStream(DigestOutputStream(OutputStream.nullOutputStream(), md))) { dos =>
writeDigestData(a, dos)
dos.flush()
}
base64.encodeToString(md.digest())
}
}
trait CheckFresh {
protected def checkFreshAsync[A, E: Etagable](entity: E)(onStale: => Future[Result])(using request: Request[A], ec: ExecutionContext): Future[Result] = {
compareEtag(request, entity).fold(
entityEtag => onStale.map(_.withHeaders("etag" -> entityEtag)),
requestEtag => Future.successful(notModified(requestEtag))
)
}
protected def checkFresh[A, E: Etagable](entity: E)(onStale: => Result)(using request: Request[A]): Result = {
compareEtag(request, entity).fold(
entityEtag => onStale.withHeaders("etag" -> entityEtag),
requestEtag => notModified(requestEtag)
)
}
private def compareEtag[A, E: Etagable](request: Request[A], entity: E) = {
val entityEtag = entity.etag
request.headers.get("If-None-Match").filter(e => e == entityEtag) match {
case Some(etag) => Right(etag)
case None => Left(entityEtag)
}
}
private def notModified(etag: String) = NotModified.withHeaders("etag" -> etag)
}
@@ -0,0 +1,32 @@
package controllers.devel
import dao.UsersDAO
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsError, JsPath, Json, Reads}
import play.api.mvc.*
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
class DevActionsController @Inject()(val controllerComponents: ControllerComponents,
val usersDAO: UsersDAO)
(implicit val ec: ExecutionContext)
extends BaseController {
private case class UserData(username: String, password: String)
private implicit val userDataReads: Reads[UserData] = (
(JsPath \ "username").read[String] and (JsPath \ "password").read[String]
)(UserData.apply)
def createUser = Action.async(parse.json) { implicit request =>
request.body.validate[UserData].fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
userData => usersDAO.create(userData.username, userData.password)
.map {
case Left(UsersDAO.UsernameTaken) => BadRequest(Json.obj("error" -> "Username exists"))
case Left(_ex) => BadRequest(Json.obj("error" -> "User creation error"))
case Right(user) => Ok(Json.toJson(user))
}
)
}
}
+24
View File
@@ -0,0 +1,24 @@
package controllers.devel
import play.api.Environment
import play.api.Mode.Dev
import play.api.mvc.*
import play.api.routing.Router.Routes
import play.api.routing.SimpleRouter
import play.api.routing.sird.{POST, UrlContext}
import javax.inject.Inject
class DevRouter @Inject()(environment: Environment, controller: DevActionsController) extends SimpleRouter {
override def routes: Routes = {
if (environment.mode == Dev) {
devRoutes
} else {
PartialFunction.empty
}
}
private val devRoutes: PartialFunction[RequestHeader, Handler] = {
case POST(p"/users") => controller.createUser()
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ class LoggingFilter @Inject()(implicit val mat: Materializer, ec: ExecutionConte
val endTime = System.currentTimeMillis() val endTime = System.currentTimeMillis()
val requestTime = endTime - startTime val requestTime = endTime - startTime
logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} $requestTime") logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} ${requestTime}ms")
result result
} }
+58
View File
@@ -0,0 +1,58 @@
package dao
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.jdbc.PostgresProfile
import slick.jdbc.PostgresProfile.api.*
import java.time.Instant
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Random
import models.{Auction, AuctionStatus, User}
import schemas.Auctions
import schemas.given
class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] {
def list_available(user: User): Future[Seq[Auction]] = {
db.run(availableScope(user).result)
}
def showAvailable(user: User, id: Long): Future[Option[Auction]] = {
db.run {
availableScope(user).filter(_.id === id).take(1).result.headOption
}
}
def list_created_by(user: User): Future[Seq[Auction]] = {
db.run(Auctions.filter(_.authorId === user.id).result)
}
def create(name: String, startingPrice: BigDecimal, author: User): Future[Auction] = {
val auction = Auction(
id = None, name = name,
gid = Random.alphanumeric.take(24).mkString,
status = AuctionStatus.Created,
authorId = author.id.get,
startingPrice = startingPrice,
currentBid = None, currentBidderId = None, currentBidAt = None,
createdAt = Instant.now()
)
create(auction)
}
def create(auction: Auction) : Future[Auction] = {
db.run((Auctions returning Auctions) += auction)
}
def changeStatus(auctionId: Long, status: AuctionStatus): Future[Boolean] = {
db.run(Auctions.filter(_.id === auctionId).map(_.status).update(status))
.map(_ == 1)
}
private def availableScope(user: User) = {
Auctions.filter(row => (row.status === AuctionStatus.Ready) && (row.authorId =!= user.id.get))
}
}
+7 -5
View File
@@ -14,10 +14,9 @@ import models.AuthToken
import schemas.AuthTokens import schemas.AuthTokens
import scala.language.postfixOps import scala.language.postfixOps
import scala.util.Random
class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) extends HasDatabaseConfigProvider[PostgresProfile] { class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext) extends HasDatabaseConfigProvider[PostgresProfile] {
private lazy val random = SecureRandom()
private lazy val base64 = Base64.getEncoder
private final lazy val tokenValidDuration = Duration.ofDays(30) private final lazy val tokenValidDuration = Duration.ofDays(30)
def find(token: String): Future[Option[AuthToken]] = { def find(token: String): Future[Option[AuthToken]] = {
@@ -26,12 +25,10 @@ class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProv
} }
def createToken(user: models.User): Future[AuthToken] = { def createToken(user: models.User): Future[AuthToken] = {
val tokenRaw = ByteBuffer.allocate(12)
random.nextBytes(tokenRaw.array())
val token = AuthToken( val token = AuthToken(
id = None, id = None,
userId = user.id.get, userId = user.id.get,
token = base64.encodeToString(tokenRaw.array()), token = Random.alphanumeric.take(12).mkString,
createdAt = Instant.now, createdAt = Instant.now,
expiresAt = Instant.now.plus(tokenValidDuration) expiresAt = Instant.now.plus(tokenValidDuration)
) )
@@ -39,6 +36,11 @@ class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProv
db.run((AuthTokens returning AuthTokens.map(_.id)) += token).map { tokenId => token.copy(id = Some(tokenId)) } db.run((AuthTokens returning AuthTokens.map(_.id)) += token).map { tokenId => token.copy(id = Some(tokenId)) }
} }
def getUser(token: AuthToken): Future[Option[models.User]] = {
val query = schemas.Users.filter(_.id === token.userId).take(1).result.headOption
db.run(query)
}
def destroy(token: AuthToken): Future[Unit] = { def destroy(token: AuthToken): Future[Unit] = {
val q = AuthTokens.filter(_.token === token.token).delete val q = AuthTokens.filter(_.token === token.token).delete
db.run(q).map{ _ => () } db.run(q).map{ _ => () }
+53 -5
View File
@@ -1,5 +1,6 @@
package dao package dao
import dao.UsersDAO.{UnexpectedError, UsernameTaken}
import de.mkammerer.argon2.Argon2Factory import de.mkammerer.argon2.Argon2Factory
import execution.CryptoExecutionContext import execution.CryptoExecutionContext
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -9,23 +10,70 @@ import slick.jdbc.PostgresProfile.api.*
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import models.User import models.User
import org.postgresql.util.{PSQLException, PSQLState}
import schemas.Users import schemas.Users
class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)(implicit val ec: ExecutionContext) import java.nio.charset.StandardCharsets.UTF_8
import scala.util.Random
class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)
(using ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] { extends HasDatabaseConfigProvider[PostgresProfile] {
private val argon2 = Argon2Factory.create() private val argon2 = Argon2Factory.create()
private object ArgonParameters {
val iterations = 3
val parallelism = 1
val memory = 65536
}
private final val dummyDigest = "$argon2i$v=19$m=65536,t=3,p=1$EB9QibLwzqkO7ne2bJoU/Q$ADkdyweXKylJORvWXbBVekURYaazkW/OplTq2wdN1So"
def authenticate(username: String, password: String): Future[Option[User]] = { def authenticate(username: String, password: String): Future[Option[User]] = {
val query = Users.filter(_.username === username).take(1).result.headOption val query = Users.filter(_.username === username).take(1).result.headOption
val userFuture = db.run(query) val userFuture = db.run(query)
userFuture.map { userOption => userFuture.map { userOption =>
for { for {
user <- userOption user <- userOption
digest <- user.passwordDigest digest <- user.passwordDigest
if argon2.verify(digest, password.getBytes("UTF-8")) } yield (user, digest)
} yield user }.map {
}(using cryptoEC) case Some((user, digest)) => (Some(user), digest)
case None => (None, dummyDigest)
}.flatMap { (userOpt, digest) =>
for {
validHash <- Future(argon2.verify(digest, password.getBytes(UTF_8)))(using cryptoEC)
opt = userOpt.flatMap(u => if validHash then Some(u) else None)
} yield opt
} }
}
def create(username: String, password: String): Future[Either[UsersDAO.RegistrationError, User]] = {
Future(argonHash(password))(using cryptoEC)
.flatMap{ passwordDigest =>
val gid = Random.alphanumeric.take(12).mkString
val user = User(
id = None,
gid = gid,
username = username,
passwordDigest = Some(passwordDigest)
)
db.run((Users returning Users) += user).map(Right(_))
}.recover {
case ex: PSQLException if ex.getSQLState == PSQLState.UNIQUE_VIOLATION.getState =>
Left(UsernameTaken)
case ex: Throwable =>
Left(UnexpectedError(ex))
}
}
private def argonHash(data: String) = argon2.hash(ArgonParameters.iterations, ArgonParameters.memory, ArgonParameters.parallelism, data.getBytes(UTF_8))
}
object UsersDAO {
sealed trait RegistrationError
case object UsernameTaken extends RegistrationError
case class UnexpectedError(cause: Throwable) extends RegistrationError
} }
+39 -2
View File
@@ -1,7 +1,44 @@
package models package models
import play.api.libs.json.{JsString, JsValue, Json, OWrites, Writes}
import java.time.Instant import java.time.Instant
case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String]) case class User(id: Option[Long], username: String, gid: String, passwordDigest: Option[String])
object User {
given writes: Writes[User] = (o: User) => Json.obj(
"gid" -> o.gid,
"username" -> o.username
)
}
case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant) case class AuthToken(id: Option[Long], userId: Long, token: String, createdAt: Instant, expiresAt: Instant)
object AuthToken {
given writes: OWrites[AuthToken] = (a: AuthToken) => Json.obj(
"token" -> a.token,
"userId" -> a.userId,
"createdAt" -> a.createdAt,
"expiresAt" -> a.expiresAt
)
}
enum AuctionStatus(val value: Short) {
case Created extends AuctionStatus(0)
case Ready extends AuctionStatus(10)
case Finished extends AuctionStatus(10000)
}
object AuctionStatus {
lazy val byValue: Map[Short, AuctionStatus] = Map.from(AuctionStatus.values.map{it => (it.value, it)})
implicit val writes: Writes[AuctionStatus] = (o: AuctionStatus) => JsString(o.toString)
}
case class Auction(id: Option[Long], name: String, status: AuctionStatus, gid: String, authorId: Long,
startingPrice: BigDecimal, currentBid: Option[BigDecimal],
currentBidderId: Option[Int], currentBidAt: Option[Instant],
createdAt: Instant)
object Auction {
implicit val writes: OWrites[Auction] = Json.writes[Auction]
}
@@ -0,0 +1,46 @@
package mqttClient
import com.google.inject.Provides
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient
import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish
import jakarta.inject.{Inject, Singleton}
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.Logging
import play.api.libs.concurrent.ActorModule
import java.nio.charset.StandardCharsets
object MessageProcessorActor extends ActorModule with Logging {
sealed trait Command
case object Stop extends Command
private case class MqttMessageReceived(publish: Mqtt5Publish) extends Command
override type Message = Command
@Singleton
class Reg @Inject()(private val mqttMessageProcessorActor: ActorRef[Command])
@Provides
def create(client: Mqtt5AsyncClient): Behavior[Command] = Behaviors.setup { context =>
client.connect().whenComplete { (_action, _throwable) =>
client.subscribeWith()
.topicFilter("test/+")
.callback{ (publish) =>
context.self ! MqttMessageReceived(publish)
}
.send()
}
Behaviors.receiveMessage[Command] {
case MqttMessageReceived(publish) =>
val topic = publish.getTopic
val payload = String(publish.getPayloadAsBytes, StandardCharsets.UTF_8)
logger.info(s"Received message $topic $payload")
Behaviors.same
case Stop => Behaviors.stopped{ () =>
client.disconnect()
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
package mqttClient
import actors.auctions.AuctionPublisher
import com.google.inject.{AbstractModule, Provides, Singleton}
import com.hivemq.client.mqtt.mqtt5.{Mqtt5AsyncClient, Mqtt5Client}
import play.api.libs.concurrent.PekkoGuiceSupport
import play.api.{Configuration, Logging}
class Module extends AbstractModule with PekkoGuiceSupport with Logging {
override def configure(): Unit = {
bindTypedActor(MessageProcessorActor, "mqtt-message-processor-actor")
bind(classOf[MessageProcessorActor.Reg]).asEagerSingleton()
bindTypedActor(AuctionPublisher, "auction-publisher") // TODO move somewhere else
}
@Provides @Singleton
def mqtt5Client(config: Configuration): Mqtt5AsyncClient = MqttClientFactory.createClient(config)
}
+27
View File
@@ -0,0 +1,27 @@
package mqttClient
import com.hivemq.client.mqtt.mqtt5.{Mqtt5AsyncClient, Mqtt5Client}
import play.api.Configuration
import java.nio.charset.StandardCharsets
object MqttClientFactory {
def createClient(configuration: Configuration, suffix: String = ""): Mqtt5AsyncClient = {
val mqttConfig = configuration.get[Configuration]("mqtt")
var builder = Mqtt5Client.builder()
.identifier(mqttConfig.get[String]("clientId") + suffix)
.serverHost(mqttConfig.get[String]("host"))
.serverPort(mqttConfig.get("port"))
mqttConfig.getOptional[String]("username").foreach { username =>
val password = mqttConfig.get[String]("password").getBytes(StandardCharsets.US_ASCII)
builder = builder.simpleAuth()
.username(username)
.password(password)
.applySimpleAuth()
}
builder.buildAsync()
}
}
+31 -6
View File
@@ -1,14 +1,14 @@
package schemas package schemas
import slick.jdbc.PostgresProfile.api._ import slick.jdbc.PostgresProfile.api.*
import models._ import models.*
import slick.lifted.ProvenShape import slick.lifted.ProvenShape
import slick.model.ForeignKeyAction.Cascade import slick.model.ForeignKeyAction.{Cascade, Restrict}
import java.time.Instant import java.time.Instant
class UsersTable(tag: Tag) extends Table[User](tag, "users") { class UsersTable(tag: Tag) extends Table[User](tag, "users") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc) def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def gid = column[String]("gid", O.Unique) def gid = column[String]("gid", O.Unique)
def username = column[String]("username", O.Unique) def username = column[String]("username", O.Unique)
def passwordDigest = column[Option[String]]("password_digest") def passwordDigest = column[Option[String]]("password_digest")
@@ -20,8 +20,8 @@ lazy val Users = TableQuery[UsersTable]
class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") { class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc) def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Int]("user_id") def userId = column[Long]("user_id")
def token = column[String]("token") def token = column[String]("token")
def createdAt = column[Instant]("created_at") def createdAt = column[Instant]("created_at")
def expiresAt = column[Instant]("expires_at") def expiresAt = column[Instant]("expires_at")
@@ -32,3 +32,28 @@ class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") {
} }
lazy val AuthTokens = TableQuery[AuthTokensTable] lazy val AuthTokens = TableQuery[AuthTokensTable]
implicit val auctionStatusColumnType: BaseColumnType[AuctionStatus] =
MappedColumnType.base[AuctionStatus, Short](
enumValue => enumValue.value,
intValue => AuctionStatus.byValue(intValue)
)
class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
def status = column[AuctionStatus]("status")
def gid = column[String]("gid", O.Unique)
def authorId = column[Long]("author_id")
def startingPrice = column[BigDecimal]("starting_price", O.SqlType("DECIMAL(20, 3)"))
def currentBid = column[Option[BigDecimal]]("current_bid", O.SqlType("DECIMAL(20, 3)"))
def currentBidderId = column[Option[Int]]("current_bidder_id")
def currentBidAt = column[Option[Instant]]("current_bid_at")
def createdAt = column[Instant]("created_at")
override def * : ProvenShape[Auction] = (id.?, name, status, gid, authorId, startingPrice, currentBid, currentBidderId, currentBidAt, createdAt).mapTo[Auction]
def author = foreignKey("users", authorId, Users)(_.id, onDelete = Restrict)
}
lazy val Auctions = TableQuery[AuctionsTable]
+3
View File
@@ -21,6 +21,9 @@ libraryDependencies += "org.playframework" %% "play-slick-evolutions" % "6.2.0"
// Source: https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm // Source: https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12" libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12"
// Source: https://mvnrepository.com/artifact/com.hivemq/hivemq-mqtt-client
libraryDependencies += "com.hivemq" % "hivemq-mqtt-client" % "1.3.17"
// Adds additional packages into Twirl // Adds additional packages into Twirl
//TwirlKeys.templateImports += "me.artemis.controllers._" //TwirlKeys.templateImports += "me.artemis.controllers._"
+7
View File
@@ -1,4 +1,6 @@
name: 'neon-vertigo-api' name: 'neon-vertigo-api'
include:
- ../shared/compose.shared.yml
services: services:
db: db:
image: "postgres:18" image: "postgres:18"
@@ -6,3 +8,8 @@ services:
- db.env - db.env
ports: ports:
- "5432:5432" - "5432:5432"
volumes:
- "db:/var/lib/postgresql"
volumes:
db:
+26
View File
@@ -2,6 +2,17 @@
play.http.filters=controllers.filters.Filters play.http.filters=controllers.filters.Filters
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.csrf.header.bypassHeaders {
X-Requested-With = "*"
}
play.filters.cors {
allowedOrigins = ["http://localhost:4321"]
exposedHeaders = ["ETag"]
}
play.modules.enabled += "mqttClient.Module"
slick.dbs.default = { slick.dbs.default = {
profile = "slick.jdbc.PostgresProfile$" profile = "slick.jdbc.PostgresProfile$"
db = { db = {
@@ -26,5 +37,20 @@ pekko {
} }
throughput = 1 throughput = 1
} }
mqtt-dispatcher {
type = Dispatcher
executor = "fork-join-executor"
fork-join-executor {
}
}
} }
} }
mqtt {
host = "localhost"
port = 1883
clientId = "neon-vertigo-api"
username = "artemis"
password = "artemis"
}
+9 -6
View File
@@ -2,13 +2,16 @@
CREATE TABLE auctions CREATE TABLE auctions
( (
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
name VARCHAR NOT NULL,
gid VARCHAR UNIQUE NOT NULL, gid VARCHAR UNIQUE NOT NULL,
author_id BIGINT NOT NULL REFERENCES users (id) ON DELETE RESTRICT, author_id BIGINT REFERENCES users (id) NOT NULL,
description VARCHAR, starting_price DECIMAL(20, 3) NOT NULL,
starting_price DECIMAL(12, 3) NOT NULL, current_bid DECIMAL(20, 3),
current_bid DECIMAL(12, 3), current_bidder_id BIGINT REFERENCES users (id),
current_bidder_id BIGINT REFERENCES users (id) ON DELETE SET NULL current_bid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
); );
-- !Downs -- !Downs
DROP TABLE auctions;
DROP TABLE auctions
+7
View File
@@ -0,0 +1,7 @@
-- !Ups
ALTER TABLE auctions
ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;
-- !Downs
ALTER TABLE auctions
DROP COLUMN status;
+2
View File
@@ -41,6 +41,8 @@
<logger name="play" level="INFO"/> <logger name="play" level="INFO"/>
<logger name="application" level="DEBUG"/> <logger name="application" level="DEBUG"/>
<logger name="mqttClient" level="DEBUG"/>
<logger name="actors" level="INFO"/>
<root level="WARN"> <root level="WARN">
<appender-ref ref="ASYNCFILE"/> <appender-ref ref="ASYNCFILE"/>
+8 -2
View File
@@ -7,9 +7,15 @@
GET / controllers.HomeController.index() GET / controllers.HomeController.index()
POST /login controllers.authentication.SessionsController.login() POST /login controllers.authentication.SessionsController.login()
# TODO: enable it later
+nocsrf
DELETE /logout controllers.authentication.SessionsController.logout() DELETE /logout controllers.authentication.SessionsController.logout()
GET /user_data controllers.HomeController.userData()
# Map static resources from the /public folder to the /assets URL path # Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
GET /auctions controllers.AuctionsController.index()
GET /auctions/:id controllers.AuctionsController.show(id: Long)
POST /auctions controllers.AuctionsController.create()
-> /-devel controllers.devel.DevRouter