diff --git a/KODA.md b/KODA.md new file mode 100644 index 0000000..e69de29 diff --git a/app/actors/LobbieActor.scala b/app/actors/LobbieActor.scala index ceac990..df97ec7 100644 --- a/app/actors/LobbieActor.scala +++ b/app/actors/LobbieActor.scala @@ -4,14 +4,11 @@ import actors.UserActor._ import akka.actor.{Actor, ActorRef} import akka.event.LoggingReceive import akka.util.Timeout -import com.typesafe.config.ConfigFactory import com.typesafe.scalalogging.LazyLogging +import repository.DeciderRepository -import java.util -import scala.collection.JavaConverters.iterableAsScalaIterableConverter import scala.collection.immutable.HashSet import scala.concurrent.duration._ -import scala.util.Try case class LobbyUser(name: String, actorRef: ActorRef) @@ -23,7 +20,6 @@ case class DeciderMap(map: String, description: Option[String] = None, var isBan */ class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with LazyLogging { - val config = ConfigFactory.load() logger.info(s"Create lobby... host: ${hostUser.actorRef.path.name}") @@ -49,11 +45,12 @@ class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with L private val mapsLobby: Set[DeciderMap] = { - val configMaps = config.getList(s"deciders.$deciderName.maps").asScala - configMaps.map(e => { - val mapConfig = e.unwrapped().asInstanceOf[util.ArrayList[String]].asScala - DeciderMap(mapConfig.head, mapConfig.tail.headOption) - }).toSet + DeciderRepository.get(deciderName) match { + case Some(d) => + d.maps.map(m => DeciderMap(m.map, m.description)).toSet + case None => + Set.empty[DeciderMap] + } } hostUser.actorRef.tell( diff --git a/app/assets/images/maps/4p_abandoned_jungle.jpg b/app/assets/images/maps/4p_abandoned_jungle.jpg new file mode 100644 index 0000000..214e3de Binary files /dev/null and b/app/assets/images/maps/4p_abandoned_jungle.jpg differ diff --git a/app/assets/images/maps/4p_ruined_greatway.jpg b/app/assets/images/maps/4p_ruined_greatway.jpg new file mode 100644 index 0000000..cf3a5d3 Binary files /dev/null and b/app/assets/images/maps/4p_ruined_greatway.jpg differ diff --git a/app/assets/images/maps/4p_sad_spires.jpg b/app/assets/images/maps/4p_sad_spires.jpg new file mode 100644 index 0000000..aa675df Binary files /dev/null and b/app/assets/images/maps/4p_sad_spires.jpg differ diff --git a/app/assets/images/maps/4p_summit.jpg b/app/assets/images/maps/4p_summit.jpg new file mode 100644 index 0000000..a050a7f Binary files /dev/null and b/app/assets/images/maps/4p_summit.jpg differ diff --git a/app/controllers/AdminController.scala b/app/controllers/AdminController.scala index cd6b5b7..50f7cc2 100644 --- a/app/controllers/AdminController.scala +++ b/app/controllers/AdminController.scala @@ -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") } } diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index 24e955b..ce4b57d 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -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)) + } } /** diff --git a/app/controllers/SecuredActions.scala b/app/controllers/SecuredActions.scala new file mode 100644 index 0000000..9101840 --- /dev/null +++ b/app/controllers/SecuredActions.scala @@ -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")) + } + } +} \ No newline at end of file diff --git a/app/repository/DeciderRepository.scala b/app/repository/DeciderRepository.scala new file mode 100644 index 0000000..edeabaf --- /dev/null +++ b/app/repository/DeciderRepository.scala @@ -0,0 +1,179 @@ +package repository + +import com.typesafe.config.{Config, ConfigFactory} + +import java.io.{File, PrintWriter} +import scala.collection.JavaConverters._ +import scala.util.Try + +/** Описание одной карты в маппуле десайдера. */ +case class DeciderMapConfig(map: String, description: Option[String] = None) + +/** Полное описание десайдера (турнира). */ +case class Decider( + key: String, + name: String, + rules: String, + raceCount: Int, + existAdditionalRaces: Boolean, + existTextFieldRaces: Boolean, + description: Boolean, + maps: List[DeciderMapConfig] +) + +/** + * Чтение и запись десайдеров. + * + * Десайдеры хранятся во внешнем HOCON-файле (admin.decidersFile, по умолчанию + * "conf/deciders.conf"). Пока этот файл не существует, десайдеры читаются из + * application.conf, поэтому приложение работает "из коробки". Как только админ + * что-то сохраняет через админку, внешний файл становится единственным источником + * правды. + */ +object DeciderRepository { + + private def appConfig: Config = ConfigFactory.load() + + /** Файл, в который админка пишет десайдеров. */ + def file: File = { + val path = Try(appConfig.getString("admin.decidersFile")).getOrElse("conf/deciders.conf") + new File(path) + } + + /** Десайдеры как объект Config (содержимое блока `deciders { ... }`). */ + def decidersConfig: Config = { + val f = file + if (f.isFile) { + Try(ConfigFactory.parseFile(f)).toOption + .filter(_.hasPath("deciders")) + .map(_.getConfig("deciders")) + .getOrElse(fallback) + } else { + fallback + } + } + + /** Переинициализировать кеш конфигуратора (вызывать после сохранения). */ + def reload(): Unit = { + ConfigFactory.invalidateCaches() + } + + private def fallback: Config = + if (appConfig.hasPath("deciders")) appConfig.getConfig("deciders") else ConfigFactory.empty() + + /** Ключи всех десайдеров (отсортированные). */ + def keys: Seq[String] = decidersConfig.root().keySet().asScala.toSeq.sorted + + /** Один десайдер по ключу. */ + def get(key: String): Option[Decider] = { + val c = decidersConfig + if (!c.hasPath(key)) None + else { + val d = c.getConfig(key) + val maps = d.getList("maps").asScala.toList.map { value => + value.unwrapped() match { + case list: java.util.List[_] => + val parts = list.asScala.map(_.toString).toList + DeciderMapConfig(parts.head, parts.lift(1).filter(_.nonEmpty)) + case other => + // Некоторые десайдеры заданы просто списком строк без описаний. + DeciderMapConfig(other.toString, None) + } + } + Some(Decider( + key = key, + name = Try(d.getString("name")).getOrElse(key), + rules = Try(d.getString("rules")).getOrElse(""), + raceCount = Try(d.getInt("raceCount")).getOrElse(1), + existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false), + existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false), + description = Try(d.getBoolean("description")).getOrElse(false), + maps = maps + )) + } + } + + /** Все десайдеры. */ + def all: Seq[Decider] = keys.flatMap(get) + + /** Создаёт или обновляет десайдера и перезаписывает весь файл десайдеров. */ + def save(decider: Decider): Unit = { + val updated = (all.filterNot(_.key == decider.key) :+ decider).sortBy(_.key) + write(updated) + } + + /** Удаляет десайдера. Возвращает true, если десайдер существовал. */ + def delete(key: String): Boolean = { + val current = all + if (current.exists(_.key == key)) { + write(current.filterNot(_.key == key)) + true + } else { + false + } + } + + private def write(deciders: Seq[Decider]): Unit = { + val sb = new StringBuilder + sb.append("# Конфигурация десайдеров.\n") + sb.append("# Файл управляется админкой (авторизация через Steam).\n") + sb.append("# Ручные правки могут быть перезаписаны при следующем сохранении.\n\n") + sb.append("deciders {\n") + deciders.foreach { d => + sb.append(" ").append(quoteKey(d.key)).append(" {\n") + sb.append(" name = ").append(quote(d.name)).append('\n') + sb.append(" raceCount = ").append(d.raceCount).append('\n') + if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n") + if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n") + if (d.description) sb.append(" description = true\n") + if (d.maps.isEmpty) { + sb.append(" maps = []\n") + } else { + sb.append(" maps = [\n") + d.maps.zipWithIndex.foreach { case (m, i) => + sb.append(" ").append(renderMap(m)) + if (i < d.maps.size - 1) sb.append(',') + sb.append('\n') + } + sb.append(" ]\n") + } + sb.append(" rules = ").append(quote(d.rules)).append('\n') + sb.append(" }\n") + } + sb.append("}\n") + + val f = file + Option(f.getParentFile).foreach(_.mkdirs()) + val writer = new PrintWriter(f, "UTF-8") + try writer.write(sb.toString) finally writer.close() + } + + private def renderMap(m: DeciderMapConfig): String = + m.description.filter(_.nonEmpty) match { + case Some(desc) => s"[${quote(m.map)}, ${quote(desc)}]" + case None => s"[${quote(m.map)}]" + } + + private def quoteKey(key: String): String = + if (key.matches("[A-Za-z0-9_-]+") && !key.forall(_.isDigit)) key else quote(key) + + /** Рендерит строку как строку в формате JSON/HOCON. */ + private def quote(value: String): String = { + val sb = new StringBuilder("\"") + value.foreach { + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + // Литерал из обратного слэша и буквы u ломает компилятор Scala: + // такая последовательность обрабатывается как Unicode-escape ещё до лексера. + // Поэтому символы добавляем по отдельности, без escape-последовательности в исходнике. + case c if c < ' ' => + sb.append(0x5c.toChar).append('u').append("%04x".format(c.toInt)) + case c => sb.append(c) + } + sb.append('"') + sb.toString + } +} diff --git a/app/repository/MapImageRepository.scala b/app/repository/MapImageRepository.scala new file mode 100644 index 0000000..743991a --- /dev/null +++ b/app/repository/MapImageRepository.scala @@ -0,0 +1,190 @@ +package repository + +import java.io.File +import java.net.{JarURLConnection, URL} +import java.nio.file.StandardCopyOption +import scala.collection.JavaConverters._ +import scala.util.Try + +/** Изображение карты, доступное для выбора в админке. */ +case class MapImage(name: String, file: String, players: Int) + +/** + * Список картинок карт из app/assets/images/maps. + * + * Картинки читаются из исходников (при запуске через `sbt run`) либо из + * classpath (когда приложение собрано и assets упакованы в jar). + */ +object MapImageRepository { + + private val ImageExtensions = Set("jpg", "jpeg", "png", "webp") + + /** Служебные картинки, которые не являются картами. */ + private val IgnoredFiles = Set("default.jpg") + private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r + + /** Каталоги с картинками: исходники и результат сборки assets. */ + private val MapDirectories = Seq( + "app/assets/images/maps", + "target/web/public/main/images/maps", + "public/images/maps" + ) + + private val ClasspathDirectory = "public/images/maps" + + /** Каталог, в который сохраняются загруженные картинки (исходники). */ + private val SourceUploadDirectory = "app/assets/images/maps" + + /** Каталоги, из которых Play раздаёт assets в dev-режиме. */ + private val RuntimeUploadDirectory = "target/web/public/main/images/maps" + + /** Максимальный размер загружаемого файла. */ + private val MaxUploadBytes = 10L * 1024 * 1024 + + /** Все карты, отсортированные по количеству игроков и имени. */ + def all: Seq[MapImage] = + loadFileNames() + .distinct + .map(toMapImage) + .sortBy(image => (groupOrder(image.players), image.name.toLowerCase)) + + /** + * Сохраняет загруженную картинку карты и возвращает её описание. + * + * Файл пишется и в исходники (app/assets), и в каталог, который раздаётся + * в dev-режиме (target/web/public/main), чтобы картинка сразу появилась + * в галерее без перезапуска приложения. + */ + def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = { + if (!source.isFile) Left("Uploaded file not found") + else if (source.length() <= 0) Left("Uploaded file is empty") + else if (source.length() > MaxUploadBytes) Left("File is too large (max 10 MB)") + else sanitizeFileName(rawFileName) match { + case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)") + case Some(safeName) => + val directories = uploadDirectories() + if (directories.isEmpty) Left("Map images directory not found") + else { + val fileName = uniqueFileName(safeName, directories) + Try { + directories.foreach { dir => + java.nio.file.Files.copy( + source.toPath, + new File(dir, fileName).toPath, + StandardCopyOption.REPLACE_EXISTING + ) + } + }.toEither match { + case Right(_) => Right(toMapImage(fileName)) + case Left(error) => Left(s"Failed to save file: ${error.getMessage}") + } + } + } + } + + /** Каталоги для записи: исходники создаются при необходимости, остальные — только существующие. */ + private def uploadDirectories(): Seq[File] = { + val source = new File(SourceUploadDirectory) + if (!source.isDirectory) source.mkdirs() + (source +: Seq(new File(RuntimeUploadDirectory))) + .filter(_.isDirectory) + } + + /** Проверяет расширение и убирает из имени всё, что может сломать путь. */ + private def sanitizeFileName(rawFileName: String): Option[String] = { + val name = Option(rawFileName) + .map(_.trim) + .map(new File(_).getName) + .getOrElse("") + val safe = name.map { c => + if (c.isLetterOrDigit || c == '_' || c == '-' || c == '.' || c == ' ' || + c == '(' || c == ')' || c == '[' || c == ']') c + else '_' + }.mkString.trim + if (safe.nonEmpty && isMapImage(safe)) Some(safe) else None + } + + /** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */ + private def uniqueFileName(fileName: String, directories: Seq[File]): String = { + def taken(name: String): Boolean = directories.exists(dir => new File(dir, name).exists()) + + if (!taken(fileName)) fileName + else { + val dot = fileName.lastIndexOf('.') + val base = fileName.substring(0, dot) + val ext = fileName.substring(dot) + Iterator.from(1) + .map(i => s"$base-$i$ext") + .find(name => !taken(name)) + .getOrElse(fileName) + } + } + + private def loadFileNames(): Seq[String] = { + val fromDisk = MapDirectories + .map(new File(_)) + .find(_.isDirectory) + .map(dir => Option(dir.listFiles()).toSeq.flatten.filter(_.isFile).map(_.getName)) + .getOrElse(Seq.empty) + + val fileNames = if (fromDisk.nonEmpty) fromDisk else loadFromClasspath() + fileNames.filter(isMapImage) + } + + private def isMapImage(fileName: String): Boolean = { + val lower = fileName.toLowerCase + ImageExtensions.exists(ext => lower.endsWith("." + ext)) && + !IgnoredFiles.contains(lower) && + IgnoredPattern.findFirstIn(lower).isEmpty + } + + private def loadFromClasspath(): Seq[String] = { + val loader = Option(getClass.getClassLoader).getOrElse(ClassLoader.getSystemClassLoader) + loader.getResources(ClasspathDirectory).asScala.toSeq.flatMap(readResource) + } + + private def readResource(url: URL): Seq[String] = url.getProtocol match { + case "file" => + Try(new File(url.toURI)).toOption + .filter(_.isDirectory) + .flatMap(dir => Option(dir.listFiles())) + .toSeq + .flatten + .filter(_.isFile) + .map(_.getName) + + case "jar" => + Try { + val connection = url.openConnection().asInstanceOf[JarURLConnection] + connection.setUseCaches(false) + val jar = connection.getJarFile + try { + jar.entries().asScala + .map(_.getName) + .filter(_.startsWith(ClasspathDirectory + "/")) + .map(_.stripPrefix(ClasspathDirectory + "/")) + .filterNot(_.contains("/")) + .toSeq + } finally jar.close() + }.getOrElse(Seq.empty) + + case _ => Seq.empty + } + + private def toMapImage(fileName: String): MapImage = { + val baseName = fileName.substring(0, fileName.lastIndexOf('.')) + MapImage(baseName, fileName, playersFrom(fileName)) + } + + private val PlayersPattern = """(?i)(?:^|\])\s*(\d+)p""".r + + /** Количество игроков из префикса имени: 2p -> 2, 4p -> 4 и т.д. */ + private def playersFrom(fileName: String): Int = + PlayersPattern.findFirstMatchIn(fileName).map(_.group(1).toInt).getOrElse(0) + + /** Порядок групп: 1x1, 2x2, 3x3, 4x4, остальные. */ + private def groupOrder(players: Int): Int = players match { + case 2 | 4 | 6 | 8 => players + case _ => 99 + } +} diff --git a/app/views/admin.scala.html b/app/views/admin.scala.html index f681e07..7898572 100644 --- a/app/views/admin.scala.html +++ b/app/views/admin.scala.html @@ -1,6 +1,656 @@ -@()(implicit r: Request[_]) +@(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages) + +
+ + + +@d.key • maps: @d.maps.size
+ + } + @if(deciders.isEmpty) { +or create a new one
+@rules
+@rules