Compare commits

..
Author SHA1 Message Date
artemis e99a194353 Sending actor data to MQTT 2026-08-27 00:22:15 +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
19 changed files with 356 additions and 39 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/target
/target
.bsp
@@ -0,0 +1,66 @@
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 models.Auction
import mqttClient.MqttClientFactory
import org.apache.pekko.actor.typed.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.Json
import java.nio.charset.StandardCharsets
import javax.inject.Singleton
object AuctionPublisher extends ActorModule with Logging {
sealed trait Command
private case object Ready extends Command
case class Publish(auction: Auction) extends Command
private final val topic = "auctions/created"
override type Message = Command
@Provides
@Singleton
def apply(configuration: Configuration): Behavior[Message] = Behaviors.setup { context =>
val client = MqttClientFactory.createClient(configuration, "-auction-publisher")
client.connect().whenComplete { (_action, _throwable) =>
context.self ! Ready
}
idle(client)
}
private def idle(client: Mqtt5AsyncClient, waitingPublishes: Seq[Auction] = List()): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Publish(auction) =>
idle(client, waitingPublishes :+ auction)
case Ready =>
for { auction <- waitingPublishes } publishAuction(client, auction)
ready(client)
}
private def ready(client: Mqtt5AsyncClient): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Publish(auction) =>
publishAuction(client, auction)
ready(client)
case Ready =>
logger.warn("Message Ready received after ")
ready(client)
}
private def publishAuction(client: Mqtt5AsyncClient, auction: Auction): Unit = {
val payload = Json.toBytes(Json.toJson(auction))
val publish = Mqtt5Publish.builder()
.topic(topic)
.payload(payload)
.qos(MqttQos.EXACTLY_ONCE)
.build()
logger.info(s"Publishing auction ${auction.id.getOrElse(-1)}")
client.publish(publish)
}
}
+41
View File
@@ -0,0 +1,41 @@
package controllers
import actors.auctions.AuctionPublisher
import controllers.authentication.AuthenticatedAction
import dao.{AuctionsDAO, AuthTokensDAO}
import models.Auction
import org.apache.pekko.actor.typed.ActorRef
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsError, JsPath, JsValue, Json, Reads, Writes}
import play.api.mvc.{BaseController, ControllerComponents}
import java.security.SecureRandom
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class AuctionsController @Inject()(val controllerComponents: ControllerComponents,
val auctionsDAO: AuctionsDAO,
val authTokensDAO: AuthTokensDAO,
val auctionPublisher: ActorRef[AuctionPublisher.Command],
implicit val ec: ExecutionContext) extends BaseController {
def create = Action.andThen(AuthenticatedAction(authTokensDAO)).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)
response = Created(json)
_ = auctionPublisher ! AuctionPublisher.Publish(auction)
} yield response
}
)
}
private case class AuctionCreateData(name: String, startingPrice: BigDecimal)
private implicit val auctionCreateDataReads: Reads[AuctionCreateData] = (
(JsPath \ "name").read[String] and (JsPath \ "starting_price").read[BigDecimal]
)(AuctionCreateData.apply)
}
@@ -2,34 +2,38 @@ package controllers.authentication
import dao.AuthTokensDAO
import jakarta.inject.Inject
import models.AuthToken
import models.{AuthToken, User}
import play.api.libs.json.*
import play.api.mvc.Results.Unauthorized
import play.api.mvc.{ActionRefiner, Request, Result, WrappedRequest}
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"
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")
.flatMap { header =>
if header.startsWith(tokenPrefix) then Some(header.substring(tokenPrefix.length).trim)
else None
}
.map(_.split(" ", 2)(1))
.filter(_.nonEmpty)
authHeader match {
case Some(token) =>
authTokensDao.find(token).map {
case Some(authToken) => Right(AuthenticatedRequest[A](authToken, request))
case None => unauthenticatedRequestError
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))
case None => unauthenticatedRequestError
}
}
}
@@ -5,7 +5,7 @@ import jakarta.inject.{Inject, Singleton}
import models.AuthToken
import play.api.libs.json.*
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}
@@ -22,10 +22,7 @@ implicit val tokenWrites: Writes[AuthToken] = (token: AuthToken) => Json.obj(
@Singleton
class SessionsController @Inject()(controllerComponents: ControllerComponents,
usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction,
implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) {
class SessionsController @Inject()(controllerComponents: ControllerComponents, usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO, implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) {
def login() = Action.async(parse.json) { implicit request =>
val loginDataResult = request.body.validate[LoginData]
loginDataResult.fold(
@@ -44,7 +41,7 @@ class SessionsController @Inject()(controllerComponents: ControllerComponents,
)
}
def logout = (Action andThen authenticatedAction).async { request =>
def logout = Action.andThen(new AuthenticatedAction(authTokensDAO)).async { request =>
authTokensDAO.destroy(request.token).map{ _ => Ok("") }
}
}
+33
View File
@@ -0,0 +1,33 @@
package dao
import models.Auction
import schemas.Auctions
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.jdbc.PostgresProfile
import slick.jdbc.PostgresProfile.api.*
import utils.Random
import java.time.Instant
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] {
def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = {
val auction = Auction(
id = None, name = name,
gid = Random.randomBase64(24),
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)
}
}
+7 -5
View File
@@ -12,12 +12,11 @@ import java.time.{Duration, Instant}
import scala.concurrent.{ExecutionContext, Future}
import models.AuthToken
import schemas.AuthTokens
import utils.Random
import scala.language.postfixOps
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]] = {
@@ -26,12 +25,10 @@ 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 = base64.encodeToString(tokenRaw.array()),
token = Random.randomBase64(12),
createdAt = Instant.now,
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)) }
}
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{ _ => () }
+19
View File
@@ -1,7 +1,26 @@
package models
import play.api.libs.json.{JsValue, Json, Writes}
import java.time.Instant
case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String])
case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant)
case class Auction(id: Option[Int], name: String, gid: String, authorId: Int,
startingPrice: BigDecimal, currentBid: Option[BigDecimal],
currentBidderId: Option[Int], currentBidAt: Option[Instant],
createdAt: Instant)
object Auction {
implicit val writes: Writes[Auction] = (a: Auction) => Json.obj(
"id" -> a.id,
"name" -> a.name,
"gid" -> a.gid,
"starting_price" -> a.startingPrice,
"current_bid" -> a.currentBid,
"current_bidder_id" -> a.currentBidderId,
"current_bid_at" -> a.currentBidAt
)
}
@@ -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()
}
}
+21 -3
View File
@@ -1,9 +1,9 @@
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
import slick.model.ForeignKeyAction.{Cascade, Restrict}
import java.time.Instant
@@ -32,3 +32,21 @@ class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") {
}
lazy val AuthTokens = TableQuery[AuthTokensTable]
class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
def gid = column[String]("gid", O.Unique)
def authorId = column[Int]("author_id")
def startingPrice = column[BigDecimal]("starting_price")
def currentBid = column[Option[BigDecimal]]("current_bid")
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, gid, authorId, startingPrice, currentBid, currentBidderId, currentBidAt, createdAt).mapTo[Auction]
def author = foreignKey("users", authorId, Users)(_.id, onDelete = Restrict)
}
lazy val Auctions = TableQuery[AuctionsTable]
+16
View File
@@ -0,0 +1,16 @@
package utils
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.util.Base64
object Random {
private lazy val random = SecureRandom()
private lazy val base64 = Base64.getEncoder
def randomBase64(bytes: Int): String = {
val tokenRaw = ByteBuffer.allocate(bytes)
random.nextBytes(tokenRaw.array())
base64.encodeToString(tokenRaw.array())
}
}
+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
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._"
+21
View File
@@ -2,6 +2,12 @@
play.http.filters=controllers.filters.Filters
play.filters.csrf.header.bypassHeaders {
X-Requested-With = "*"
}
play.modules.enabled += "mqttClient.Module"
slick.dbs.default = {
profile = "slick.jdbc.PostgresProfile$"
db = {
@@ -26,5 +32,20 @@ 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"
}
+9 -6
View File
@@ -2,13 +2,16 @@
CREATE TABLE auctions
(
id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
name VARCHAR NOT NULL,
gid VARCHAR UNIQUE 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
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
);
-- !Downs
DROP TABLE auctions;
DROP TABLE auctions
+2
View File
@@ -41,6 +41,8 @@
<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 -2
View File
@@ -7,9 +7,9 @@
GET / controllers.HomeController.index()
POST /login controllers.authentication.SessionsController.login()
# TODO: enable it later
+nocsrf
DELETE /logout controllers.authentication.SessionsController.logout()
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
POST /auctions controllers.AuctionsController.create()