Decider admin

This commit is contained in:
anibus
2026-10-24 22:02:29 +03:00
parent 3cad3aec4b
commit 0212f8b6d0
18 changed files with 1362 additions and 71 deletions
+220 -31
View File
@@ -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")
}
}
+10 -8
View File
@@ -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))
}
}
/**
+59
View File
@@ -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"))
}
}
}