Compare commits

..
Author SHA1 Message Date
artemis e7bf5c1460 (WIP) Creating auctions 2026-07-17 23:24:33 +03:00
26 changed files with 70 additions and 650 deletions
+1
View File
@@ -0,0 +1 @@
{"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,5 +8,3 @@ hs_err_pid*
/project/project/target/
/project/target
/target
.bsp
-103
View File
@@ -1,103 +0,0 @@
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
)
}
}
-47
View File
@@ -1,47 +0,0 @@
package controllers
import actors.auctions.AuctionPublisher
import controllers.authentication.AuthenticatedAction
import dao.{AuctionsDAO, AuthTokensDAO}
import models.AuctionStatus.Ready
import models.{Auction, User}
import org.apache.pekko.actor.typed.ActorRef
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.*
import play.api.mvc.{BaseController, ControllerComponents}
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
@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 {
def index = authenticatedAction.async { request =>
for {
auctions <- auctionsDAO.list_available(request.user)
json = Json.toJson(auctions)
} yield Ok(json)
}
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]
}
+4 -37
View File
@@ -1,26 +1,15 @@
package controllers
import javax.inject.*
import play.api.*
import play.api.mvc.*
import authentication.{AuthenticatedAction, AuthenticatedRequest}
import caching.{Etagable, checkFresh}
import models.User
import play.api.libs.json.Json
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets.UTF_8
import java.security.MessageDigest
import java.util.Base64
import javax.inject._
import play.api._
import play.api.mvc._
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents,
authenticatedAction: AuthenticatedAction)
extends BaseController with Logging {
class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {
/**
* Create an Action to render an HTML page.
@@ -32,26 +21,4 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents,
def index() = Action { implicit request: Request[AnyContent] =>
Ok(views.html.index())
}
def userData = authenticatedAction { implicit request: AuthenticatedRequest[AnyContent] =>
val user = request.user
request.checkFresh(user) {
// TODO: put the max-age somewhere into config
Ok(Json.toJson(user)).withHeaders("Etag" -> user.etag, "Cache-Control" -> "private, max-age=15, must-revalidate")
}
}
private given Etagable[models.User] with {
private val base64 = Base64.getUrlEncoder
extension (a: User)
override def etag: String = {
val md = MessageDigest.getInstance("SHA-1")
md.update(ByteBuffer.allocate(8).putLong(a.id.getOrElse(0)))
md.update(a.gid.getBytes(UTF_8))
md.update(a.username.getBytes(UTF_8))
base64.encodeToString(md.digest())
}
}
}
@@ -2,39 +2,34 @@ package controllers.authentication
import dao.AuthTokensDAO
import jakarta.inject.Inject
import models.{AuthToken, User}
import models.AuthToken
import play.api.libs.json.*
import play.api.mvc.Results.Unauthorized
import play.api.mvc.{ActionBuilder, ActionRefiner, AnyContent, BodyParsers, Request, Result, WrappedRequest}
import play.api.mvc.{ActionRefiner, Request, Result, WrappedRequest}
import scala.concurrent.{ExecutionContext, Future}
class AuthenticatedRequest[A](val token: AuthToken, val user: User, request: Request[A]) extends WrappedRequest[A](request)
class AuthenticatedRequest[A](val token: AuthToken, 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]]] = {
val unauthenticatedRequestError = Left(Unauthorized(Json.obj("error" -> "Unauthenticated")))
val authHeader = request.headers.get("Authorization")
.map(_.split(" ", 2)(1))
.flatMap { header =>
if header.startsWith(tokenPrefix) then Some(header.substring(tokenPrefix.length).trim)
else None
}
.filter(_.nonEmpty)
authHeader match {
case Some(token) =>
authTokensDao.find(token).flatMap {
case Some(authToken) => findUser(request, authToken)
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))
authTokensDao.find(token).map {
case Some(authToken) => Right(AuthenticatedRequest[A](authToken, request))
case None => unauthenticatedRequestError
}
case None => Future.successful(unauthenticatedRequestError)
}
}
}
@@ -5,49 +5,46 @@ import jakarta.inject.{Inject, Singleton}
import models.AuthToken
import play.api.libs.json.*
import play.api.libs.functional.syntax.*
import play.api.mvc.{AbstractController, ControllerComponents}
import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents}
import scala.concurrent.{ExecutionContext, Future}
object SessionsController {
case class LoginData(username: String, password: String)
given Reads[LoginData] = Json.reads[LoginData]
}
implicit val loginDataReads: Reads[LoginData] = (
(JsPath \ "username").read[String] and
(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
class SessionsController @Inject()(
controllerComponents: ControllerComponents,
usersDAO: UsersDAO,
authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction)
(implicit val ec: ExecutionContext)
extends AbstractController(controllerComponents) {
import SessionsController.{LoginData, given}
class SessionsController @Inject()(controllerComponents: ControllerComponents,
usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction,
implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) {
def login() = Action.async(parse.json) { implicit request =>
val loginDataResult = request.body.validate[LoginData]
loginDataResult.fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
errors => {
Future.successful(BadRequest(Json.obj("message" -> JsError.toJson(errors))))
},
loginData => {
usersDAO.authenticate(loginData.username, loginData.password).flatMap { userOption =>
userOption.fold(
Future.successful(UnprocessableEntity(Json.obj("message" -> "Invalid authentication")))
){ user =>
authTokensDAO.createToken(user).map { token =>
val json = Json.toJsObject(token) ++ Json.obj(
"userId" -> user.id.get,
"userGid" -> user.gid
)
Ok(json)
}
authTokensDAO.createToken(user).map(token => Ok(Json.toJson(token)))
}
}
}
)
}
def logout = authenticatedAction.async { request =>
def logout = (Action andThen authenticatedAction).async { request =>
authTokensDAO.destroy(request.token).map{ _ => Ok("") }
}
}
-26
View File
@@ -1,26 +0,0 @@
package controllers.caching
import play.api.mvc.Results.NotModified
import play.api.mvc.{Request, Result}
import scala.concurrent.{ExecutionContext, Future}
trait Etagable[A]:
extension (a: A) def etag: String
extension [A, R[X] <: Request[X], E: Etagable](request: R[A]) {
def checkFreshAsync(entity: E)(onStale: => Future[Result])(using ec: ExecutionContext): Future[Result] = {
compareEtag(entity).fold(onStale)(
etag => Future.successful(NotModified.withHeaders("ETag" -> etag))
)
}
def checkFresh(entity: E)(onStale: => Result): Result = {
compareEtag(entity).fold(onStale)(
etag => NotModified.withHeaders("ETag" -> etag)
)
}
private def compareEtag(entity: E) = request.headers.get("If-None-Match").filter(e => e == entity.etag)
}
@@ -1,32 +0,0 @@
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
@@ -1,24 +0,0 @@
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 requestTime = endTime - startTime
logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} ${requestTime}ms")
logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} $requestTime")
result
}
-50
View File
@@ -1,50 +0,0 @@
package dao
import models.{Auction, AuctionStatus}
import schemas.Auctions
import schemas.given
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
class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] {
def list_available(user: models.User): Future[Seq[Auction]] = {
db.run {
Auctions.filter(row => (row.status === AuctionStatus.Ready) && (row.authorId =!= user.id.get)).result
}
}
def list_created_by(user: models.User): Future[Seq[Auction]] = {
db.run(Auctions.filter(_.authorId === user.id).result)
}
def create(name: String, startingPrice: BigDecimal, author: models.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)
}
}
+5 -7
View File
@@ -14,9 +14,10 @@ import models.AuthToken
import schemas.AuthTokens
import scala.language.postfixOps
import scala.util.Random
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)
def find(token: String): Future[Option[AuthToken]] = {
@@ -25,10 +26,12 @@ class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProv
}
def createToken(user: models.User): Future[AuthToken] = {
val tokenRaw = ByteBuffer.allocate(12)
random.nextBytes(tokenRaw.array())
val token = AuthToken(
id = None,
userId = user.id.get,
token = Random.alphanumeric.take(12).mkString,
token = base64.encodeToString(tokenRaw.array()),
createdAt = Instant.now,
expiresAt = Instant.now.plus(tokenValidDuration)
)
@@ -36,11 +39,6 @@ class AuthTokensDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProv
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] = {
val q = AuthTokens.filter(_.token === token.token).delete
db.run(q).map{ _ => () }
+5 -53
View File
@@ -1,6 +1,5 @@
package dao
import dao.UsersDAO.{UnexpectedError, UsernameTaken}
import de.mkammerer.argon2.Argon2Factory
import execution.CryptoExecutionContext
import jakarta.inject.Inject
@@ -10,70 +9,23 @@ import slick.jdbc.PostgresProfile.api.*
import scala.concurrent.{ExecutionContext, Future}
import models.User
import org.postgresql.util.{PSQLException, PSQLState}
import schemas.Users
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)
class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] {
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]] = {
val query = Users.filter(_.username === username).take(1).result.headOption
val userFuture = db.run(query)
userFuture.map { userOption =>
for {
user <- userOption
digest <- user.passwordDigest
} yield (user, digest)
}.map {
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
if argon2.verify(digest, password.getBytes("UTF-8"))
} yield user
}(using cryptoEC)
}
}
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
}
+2 -39
View File
@@ -1,44 +1,7 @@
package models
import play.api.libs.json.{JsString, JsValue, Json, OWrites, Writes}
import java.time.Instant
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 User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String])
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]
}
case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant)
@@ -1,46 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,27 +0,0 @@
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()
}
}
+6 -31
View File
@@ -1,14 +1,14 @@
package schemas
import slick.jdbc.PostgresProfile.api.*
import models.*
import slick.jdbc.PostgresProfile.api._
import models._
import slick.lifted.ProvenShape
import slick.model.ForeignKeyAction.{Cascade, Restrict}
import slick.model.ForeignKeyAction.Cascade
import java.time.Instant
class UsersTable(tag: Tag) extends Table[User](tag, "users") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def gid = column[String]("gid", O.Unique)
def username = column[String]("username", O.Unique)
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") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Long]("user_id")
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Int]("user_id")
def token = column[String]("token")
def createdAt = column[Instant]("created_at")
def expiresAt = column[Instant]("expires_at")
@@ -32,28 +32,3 @@ class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") {
}
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,9 +21,6 @@ libraryDependencies += "org.playframework" %% "play-slick-evolutions" % "6.2.0"
// Source: https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm
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
//TwirlKeys.templateImports += "me.artemis.controllers._"
-7
View File
@@ -1,6 +1,4 @@
name: 'neon-vertigo-api'
include:
- ../shared/compose.shared.yml
services:
db:
image: "postgres:18"
@@ -8,8 +6,3 @@ services:
- db.env
ports:
- "5432:5432"
volumes:
- "db:/var/lib/postgresql"
volumes:
db:
-26
View File
@@ -2,17 +2,6 @@
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 = {
profile = "slick.jdbc.PostgresProfile$"
db = {
@@ -37,20 +26,5 @@ pekko {
}
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"
}
+6 -9
View File
@@ -2,16 +2,13 @@
CREATE TABLE auctions
(
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
name VARCHAR NOT NULL,
gid VARCHAR UNIQUE NOT NULL,
author_id BIGINT REFERENCES users (id) NOT NULL,
starting_price DECIMAL(20, 3) NOT NULL,
current_bid DECIMAL(20, 3),
current_bidder_id BIGINT REFERENCES users (id),
current_bid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
author_id BIGINT NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
description VARCHAR,
starting_price DECIMAL(12, 3) NOT NULL,
current_bid DECIMAL(12, 3),
current_bidder_id BIGINT REFERENCES users (id) ON DELETE SET NULL
);
-- !Downs
DROP TABLE auctions
DROP TABLE auctions;
-7
View File
@@ -1,7 +0,0 @@
-- !Ups
ALTER TABLE auctions
ADD COLUMN status SMALLINT NOT NULL DEFAULT 0;
-- !Downs
ALTER TABLE auctions
DROP COLUMN status;
-2
View File
@@ -41,8 +41,6 @@
<logger name="play" level="INFO"/>
<logger name="application" level="DEBUG"/>
<logger name="mqttClient" level="DEBUG"/>
<logger name="actors" level="INFO"/>
<root level="WARN">
<appender-ref ref="ASYNCFILE"/>
+2 -7
View File
@@ -7,14 +7,9 @@
GET / controllers.HomeController.index()
POST /login controllers.authentication.SessionsController.login()
# TODO: enable it later
+nocsrf
DELETE /logout controllers.authentication.SessionsController.logout()
GET /user_data controllers.HomeController.userData()
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
GET /auctions controllers.AuctionsController.index()
POST /auctions controllers.AuctionsController.create()
-> /-devel controllers.devel.DevRouter