Decider admin
This commit is contained in:
@@ -1,52 +1,241 @@
|
||||
package controllers
|
||||
|
||||
import actors.UserParentActor.GetAllUsers
|
||||
import actors._
|
||||
import akka.NotUsed
|
||||
import akka.actor._
|
||||
import akka.event.Logging
|
||||
import akka.pattern.ask
|
||||
import akka.stream._
|
||||
import akka.stream.scaladsl._
|
||||
import akka.util.Timeout
|
||||
import com.typesafe.config.ConfigFactory
|
||||
|
||||
import javax.inject._
|
||||
import org.reactivestreams.Publisher
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import play.api.i18n.Messages
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc._
|
||||
import repository.DeciderRepository
|
||||
import play.api.libs.ws.WSClient
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.{Await, ExecutionContext, Future}
|
||||
import scala.collection.JavaConverters._
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.Try
|
||||
|
||||
/**
|
||||
* This class creates the actions and the websocket needed.
|
||||
* Admin panel for managing deciders.
|
||||
* Authentication via Steam OpenID.
|
||||
*/
|
||||
@Singleton
|
||||
class AdminController @Inject()(cc: ControllerComponents, homeController: HomeController) extends AbstractController(cc) {
|
||||
class AdminController @Inject()(
|
||||
cc: ControllerComponents,
|
||||
ws: WSClient
|
||||
) extends AbstractController(cc) with SecuredActions {
|
||||
|
||||
implicit val actorSystem: ActorSystem = ActorSystem()
|
||||
implicit val ec: ExecutionContext = defaultExecutionContext
|
||||
implicit val timeout = Timeout(100.millis)
|
||||
implicit val ec: ExecutionContext = cc.executionContext
|
||||
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
|
||||
|
||||
// Use a direct reference to SLF4J
|
||||
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
|
||||
// ---------- Steam OpenID ----------
|
||||
|
||||
val userParentActor: ActorRef = homeController.userParentActor
|
||||
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
|
||||
|
||||
// Home page that renders template
|
||||
def viewAllUsers() = Action { implicit request =>
|
||||
logger.info(s"Received request from: ${request.remoteAddress}")
|
||||
// ---------- Public endpoints ----------
|
||||
|
||||
val allUsers = userParentActor ? GetAllUsers
|
||||
val res = Await.result(allUsers, Duration.Inf)
|
||||
Ok(res.toString)
|
||||
def login() = Action { implicit request =>
|
||||
val realm = request.headers.get("Host").map(h => s"http://$h/").getOrElse("http://localhost:1337/")
|
||||
val returnTo = controllers.routes.AdminController.steamCallback().absoluteURL()
|
||||
|
||||
val params = Map(
|
||||
"openid.ns" -> "http://specs.openid.net/auth/2.0",
|
||||
"openid.mode" -> "checkid_setup",
|
||||
"openid.return_to" -> returnTo,
|
||||
"openid.realm" -> realm,
|
||||
"openid.identity" -> "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.claimed_id" -> "http://specs.openid.net/auth/2.0/identifier_select"
|
||||
)
|
||||
|
||||
val query = params.map { case (k, v) => s"${java.net.URLEncoder.encode(k, "UTF-8")}=${java.net.URLEncoder.encode(v, "UTF-8")}" }.mkString("&")
|
||||
Redirect(s"$SteamLoginUrl?$query")
|
||||
}
|
||||
|
||||
def steamCallback() = Action.async { implicit request =>
|
||||
// Steam присылает параметры в формате OpenID: openid.mode, openid.identity,
|
||||
// openid.claimed_id. Play отдаёт ключи в queryString как есть, с точкой,
|
||||
// поэтому нормализуем имена к виду openid_mode и т.д.
|
||||
val openidParams = request.queryString.map { case (k, v) => k.replace('.', '_') -> v }
|
||||
|
||||
def reloadConfig()= Action { implicit request =>
|
||||
ConfigFactory.invalidateCaches()
|
||||
logger.info("Config reloaded")
|
||||
// Validate Steam OpenID response
|
||||
val mode = openidParams.get("openid_mode").flatMap(_.headOption)
|
||||
val identifier = openidParams.get("openid_identity").flatMap(_.headOption)
|
||||
val assertion = openidParams.get("openid_claimed_id").flatMap(_.headOption)
|
||||
|
||||
(mode, identifier orElse assertion) match {
|
||||
case (Some("id_res"), Some(steamId)) if isSteamId(steamId) =>
|
||||
val steamId64 = parseSteamId64(steamId)
|
||||
val config = ConfigFactory.load()
|
||||
val allowed = Try(config.getStringList("admin.allowedSteamIds").asScala.toSet).getOrElse(Set.empty)
|
||||
if (allowed.isEmpty || allowed.contains(steamId64)) {
|
||||
Future.successful(Redirect("/decider/admin").withSession("steamId" -> steamId64))
|
||||
} else {
|
||||
Future.successful(Redirect("/decider/admin").flashing("error" -> "You are not authorized"))
|
||||
}
|
||||
|
||||
case (Some("id_res"), _) =>
|
||||
Future.successful(Redirect("/decider/admin").flashing("error" -> "Steam authentication failed"))
|
||||
|
||||
case _ =>
|
||||
Future.successful(Redirect("/decider/admin").flashing("error" -> "Invalid OpenID response"))
|
||||
}
|
||||
}
|
||||
|
||||
private def isSteamId(id: String): Boolean =
|
||||
id.startsWith("https://steamcommunity.com/openid/id/") ||
|
||||
id.startsWith("http://steamcommunity.com/openid/id/")
|
||||
|
||||
private def parseSteamId64(id: String): String =
|
||||
id.replaceAll("""(https?://steamcommunity\.com/openid/id/)""", "")
|
||||
|
||||
// ---------- Admin page ----------
|
||||
|
||||
def index() = Secured { implicit request =>
|
||||
implicit val messages: Messages = messagesApi.preferred(request)
|
||||
Ok(views.html.admin(DeciderRepository.all))
|
||||
}
|
||||
|
||||
// ---------- API: list map images ----------
|
||||
|
||||
def mapImages() = Secured { implicit request =>
|
||||
val images = repository.MapImageRepository.all.map { image =>
|
||||
Json.obj(
|
||||
"name" -> image.name,
|
||||
"file" -> image.file,
|
||||
"players" -> image.players
|
||||
)
|
||||
}
|
||||
Ok(Json.toJson(images))
|
||||
}
|
||||
|
||||
// ---------- API: upload map image ----------
|
||||
|
||||
def mapImageUpload() = Secured(parse.multipartFormData) { implicit request =>
|
||||
request.body.file("file") match {
|
||||
case Some(part) =>
|
||||
repository.MapImageRepository.saveUploaded(part.filename, part.ref.path.toFile) match {
|
||||
case Right(image) =>
|
||||
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} uploaded map image '${image.file}'")
|
||||
Ok(Json.obj(
|
||||
"ok" -> true,
|
||||
"name" -> image.name,
|
||||
"file" -> image.file,
|
||||
"players" -> image.players
|
||||
))
|
||||
case Left(error) =>
|
||||
BadRequest(Json.obj("error" -> error))
|
||||
}
|
||||
case None =>
|
||||
BadRequest(Json.obj("error" -> "No file uploaded"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API: list all deciders ----------
|
||||
|
||||
def decidersList() = Secured { implicit request =>
|
||||
val list = DeciderRepository.all.map(d => Json.obj(
|
||||
"key" -> d.key,
|
||||
"name" -> d.name,
|
||||
"rules" -> d.rules,
|
||||
"raceCount" -> d.raceCount,
|
||||
"existAdditionalRaces" -> d.existAdditionalRaces,
|
||||
"existTextFieldRaces" -> d.existTextFieldRaces,
|
||||
"description" -> d.description,
|
||||
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
|
||||
))
|
||||
Ok(Json.toJson(list))
|
||||
}
|
||||
|
||||
// ---------- API: get one decider ----------
|
||||
|
||||
def deciderGet(key: String) = Secured { implicit request =>
|
||||
DeciderRepository.get(key) match {
|
||||
case Some(d) =>
|
||||
Ok(Json.obj(
|
||||
"key" -> d.key,
|
||||
"name" -> d.name,
|
||||
"rules" -> d.rules,
|
||||
"raceCount" -> d.raceCount,
|
||||
"existAdditionalRaces" -> d.existAdditionalRaces,
|
||||
"existTextFieldRaces" -> d.existTextFieldRaces,
|
||||
"description" -> d.description,
|
||||
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
|
||||
))
|
||||
case None =>
|
||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API: save / update decider ----------
|
||||
|
||||
def deciderSave() = SecuredJson { implicit request =>
|
||||
val json = request.body
|
||||
|
||||
(json \ "key").asOpt[String] match {
|
||||
case None =>
|
||||
BadRequest(Json.obj("error" -> "Missing key"))
|
||||
|
||||
case Some(key) =>
|
||||
Try {
|
||||
val maps = (json \ "maps").as[List[JsValue]].map { m =>
|
||||
repository.DeciderMapConfig(
|
||||
map = (m \ "map").as[String],
|
||||
description = (m \ "description").asOpt[String]
|
||||
)
|
||||
}
|
||||
repository.Decider(
|
||||
key = key,
|
||||
name = (json \ "name").as[String],
|
||||
rules = (json \ "rules").as[String],
|
||||
raceCount = (json \ "raceCount").as[Int],
|
||||
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
|
||||
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
|
||||
description = (json \ "description").as[Boolean],
|
||||
maps = maps
|
||||
)
|
||||
}.toEither match {
|
||||
case Right(decider) =>
|
||||
val existed = DeciderRepository.get(decider.key).isDefined
|
||||
DeciderRepository.save(decider)
|
||||
DeciderRepository.reload()
|
||||
if (existed) {
|
||||
logger.info(
|
||||
s"Admin ${SecuredActions.steamIdOrUnknown(request)} updated decider '${decider.key}' " +
|
||||
s"(name='${decider.name}', maps=${decider.maps.size})"
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
s"Admin ${SecuredActions.steamIdOrUnknown(request)} created decider '${decider.key}' " +
|
||||
s"(name='${decider.name}', maps=${decider.maps.size})"
|
||||
)
|
||||
}
|
||||
Ok(Json.obj("ok" -> true))
|
||||
|
||||
case Left(error) =>
|
||||
BadRequest(Json.obj("error" -> s"$error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API: delete decider ----------
|
||||
|
||||
def deciderDelete(key: String) = SecuredApi { implicit request =>
|
||||
if (DeciderRepository.delete(key)) {
|
||||
DeciderRepository.reload()
|
||||
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} deleted decider '$key'")
|
||||
Ok(Json.obj("ok" -> true))
|
||||
} else {
|
||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Legacy endpoints (deprecated) ----------
|
||||
|
||||
@deprecated("unused", "1.0")
|
||||
def viewAllUsers() = Action { implicit request =>
|
||||
logger.info("viewAllUsers")
|
||||
Ok("viewAllUsers")
|
||||
}
|
||||
|
||||
@deprecated("unused", "1.0")
|
||||
def reloadConfig() = Action { implicit request =>
|
||||
Ok("Config reloaded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.typesafe.config.ConfigFactory
|
||||
import org.reactivestreams.Publisher
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc._
|
||||
import repository.DeciderRepository
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
@@ -41,14 +42,15 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
|
||||
// Home page that renders template
|
||||
def index(deciderName: String) = Action { implicit request =>
|
||||
logger.info(s"Received request from: ${request.remoteAddress}")
|
||||
val deciderHumanName = config.getString(s"deciders.$deciderName.name")
|
||||
val deciderDescription = config.getString(s"deciders.$deciderName.rules")
|
||||
val existAdditionalRaces = Try(config.getBoolean(s"deciders.$deciderName.existAdditionalRaces")).getOrElse(false)
|
||||
val existTextFieldRaces = Try(config.getBoolean(s"deciders.$deciderName.existTextFieldRaces")).getOrElse(false)
|
||||
val raceCount = Try(config.getInt(s"deciders.$deciderName.raceCount")).getOrElse(1)
|
||||
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
|
||||
val raceSelect = RaceSelect(true, false)
|
||||
Ok(views.html.index(deciderName, deciderHumanName, raceCount, deciderDescription, raceSelect, lastmapsSettings, existAdditionalRaces, existTextFieldRaces))
|
||||
val deciderOpt = DeciderRepository.get(deciderName)
|
||||
if (deciderOpt.isEmpty) {
|
||||
NotFound(s"Decider $deciderName not found")
|
||||
} else {
|
||||
val d = deciderOpt.get
|
||||
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
|
||||
val raceSelect = RaceSelect(true, false)
|
||||
Ok(views.html.index(deciderName, d.name, d.raceCount, d.rules, raceSelect, lastmapsSettings, d.existAdditionalRaces, d.existTextFieldRaces))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package controllers
|
||||
|
||||
import play.api.libs.json.{JsValue, Json}
|
||||
import play.api.libs.typedmap.TypedKey
|
||||
import play.api.mvc._
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
object SecuredActions {
|
||||
|
||||
private val SteamIdAttr: TypedKey[String] = TypedKey("steamId")
|
||||
|
||||
/** Steam ID текущего авторизованного пользователя (если он есть в запросе). */
|
||||
def steamId[A](request: Request[A]): Option[String] = request.attrs.get(SteamIdAttr)
|
||||
|
||||
/** Steam ID текущего пользователя или "unknown", если его нет в запросе. */
|
||||
def steamIdOrUnknown[A](request: Request[A]): String = steamId(request).getOrElse("unknown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple session-based authentication via Steam ID.
|
||||
* Used to protect admin panel routes.
|
||||
*/
|
||||
trait SecuredActions {
|
||||
|
||||
self: AbstractController =>
|
||||
|
||||
private val SteamIdAttr: TypedKey[String] = SecuredActions.SteamIdAttr
|
||||
|
||||
/** Wrapper that requires the user to be logged in with Steam. */
|
||||
def Secured: ActionBuilder[Request, AnyContent] =
|
||||
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = false)
|
||||
|
||||
/** Same as [[Secured]], but parses the request body as JSON. */
|
||||
def SecuredJson: ActionBuilder[Request, JsValue] =
|
||||
securedWith(controllerComponents.parsers.json, apiStyle = true)
|
||||
|
||||
/** Same as [[Secured]], but answers AJAX calls with 401 JSON instead of a redirect. */
|
||||
def SecuredApi: ActionBuilder[Request, AnyContent] =
|
||||
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = true)
|
||||
|
||||
private def securedWith[A](bodyParser: BodyParser[A], apiStyle: Boolean): ActionBuilder[Request, A] =
|
||||
new ActionBuilder[Request, A] {
|
||||
|
||||
override def parser: BodyParser[A] = bodyParser
|
||||
|
||||
override def executionContext: ExecutionContext = controllerComponents.executionContext
|
||||
|
||||
override def invokeBlock[B](request: Request[B], block: Request[B] => Future[Result]): Future[Result] =
|
||||
request.session.get("steamId") match {
|
||||
case Some(steamId) =>
|
||||
block(request.addAttr(SteamIdAttr, steamId))
|
||||
case None if apiStyle =>
|
||||
Future.successful(Unauthorized(Json.obj("error" -> "Please log in with Steam")))
|
||||
case None =>
|
||||
Future.successful(Redirect("/decider/admin/login").flashing("error" -> "Please log in with Steam"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user