diff --git a/app/controllers/AdminController.scala b/app/controllers/AdminController.scala index 50f7cc2..bc00c41 100644 --- a/app/controllers/AdminController.scala +++ b/app/controllers/AdminController.scala @@ -25,6 +25,27 @@ class AdminController @Inject()( implicit val ec: ExecutionContext = cc.executionContext private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController") + // ---------- Ownership ---------- + + private def currentSteamId[A](request: Request[A]): String = + SecuredActions.steamIdOrUnknown(request) + + /** Редактировать и удалять десайдера может только его владелец. */ + private def owns[A](decider: repository.Decider, request: Request[A]): Boolean = + decider.ownerSteamId.contains(currentSteamId(request)) + + private def deciderJson(d: repository.Decider): JsObject = Json.obj( + "key" -> d.key, + "name" -> d.name, + "rules" -> d.rules, + "raceCount" -> d.raceCount, + "existAdditionalRaces" -> d.existAdditionalRaces, + "existTextFieldRaces" -> d.existTextFieldRaces, + "description" -> d.description, + "ownerSteamId" -> d.ownerSteamId, + "maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description)) + ) + // ---------- Steam OpenID ---------- private val SteamLoginUrl = "https://steamcommunity.com/openid/login" @@ -89,7 +110,7 @@ class AdminController @Inject()( def index() = Secured { implicit request => implicit val messages: Messages = messagesApi.preferred(request) - Ok(views.html.admin(DeciderRepository.all)) + Ok(views.html.admin(DeciderRepository.all.filter(d => owns(d, request)))) } // ---------- API: list map images ---------- @@ -130,16 +151,7 @@ class AdminController @Inject()( // ---------- 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)) - )) + val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson) Ok(Json.toJson(list)) } @@ -147,17 +159,10 @@ class AdminController @Inject()( 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 Some(d) if owns(d, request) => + Ok(deciderJson(d)) + case Some(_) => + Forbidden(Json.obj("error" -> "You don't own this decider")) case None => NotFound(Json.obj("error" -> s"Decider $key not found")) } @@ -167,49 +172,59 @@ class AdminController @Inject()( def deciderSave() = SecuredJson { implicit request => val json = request.body + val steamId = currentSteamId(request) (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})" + val existing = DeciderRepository.get(key) + if (existing.exists(d => !owns(d, request))) { + Forbidden(Json.obj("error" -> "You don't own this decider")) + } else { + val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("") + val duplicateName = existing.isEmpty && name.nonEmpty && + DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name)) + if (duplicateName) { + Conflict(Json.obj("error" -> s"A decider named '$name' already exists")) + } else { + 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, + // Владельца из запроса игнорируем: он либо уже назначен, либо + // им становится автор нового десайдера. + ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId)) ) - } 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)) + }.toEither match { + case Right(decider) => + DeciderRepository.save(decider) + DeciderRepository.reload() + val action = if (existing.isDefined) "updated" else "created" + logger.info( + s"Admin $steamId $action decider '${decider.key}' " + + s"(name='${decider.name}', maps=${decider.maps.size}, " + + s"owner=${decider.ownerSteamId.getOrElse("-")})" + ) + Ok(Json.obj("ok" -> true)) - case Left(error) => - BadRequest(Json.obj("error" -> s"$error")) + case Left(error) => + BadRequest(Json.obj("error" -> s"$error")) + } + } } } } @@ -217,12 +232,17 @@ class AdminController @Inject()( // ---------- 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")) + DeciderRepository.get(key) match { + case Some(d) if !owns(d, request) => + Forbidden(Json.obj("error" -> "You don't own this decider")) + case _ => + if (DeciderRepository.delete(key)) { + DeciderRepository.reload() + logger.info(s"Admin ${currentSteamId(request)} deleted decider '$key'") + Ok(Json.obj("ok" -> true)) + } else { + NotFound(Json.obj("error" -> s"Decider $key not found")) + } } } diff --git a/app/controllers/MapImageController.scala b/app/controllers/MapImageController.scala new file mode 100644 index 0000000..063f699 --- /dev/null +++ b/app/controllers/MapImageController.scala @@ -0,0 +1,38 @@ +package controllers + +import java.io.File +import javax.inject.{Inject, Singleton} + +import play.api.mvc.{AbstractController, ControllerComponents} +import repository.MapImageRepository + +import scala.concurrent.ExecutionContext + +/** + * Раздаёт картинки карт из каталога, заданного в конфиге (`maps.imagesDir`). + * + * Нужен потому, что каталог может находиться вне `public`/`app/assets` + * (например, на сервере), и стандартный Assets его не отдаст. + */ +@Singleton +class MapImageController @Inject()(cc: ControllerComponents) extends AbstractController(cc) { + + implicit val ec: ExecutionContext = cc.executionContext + + def image(file: String) = Action { implicit request => + MapImageRepository.findFile(file) match { + case Some(image) => + Ok.sendFile(image, inline = true) + .as(contentType(image)) + .withHeaders(CACHE_CONTROL -> "public, max-age=86400") + case None => NotFound + } + } + + private def contentType(file: File): String = { + val name = file.getName.toLowerCase + if (name.endsWith(".png")) "image/png" + else if (name.endsWith(".webp")) "image/webp" + else "image/jpeg" + } +} diff --git a/app/repository/DeciderRepository.scala b/app/repository/DeciderRepository.scala index edeabaf..6d96085 100644 --- a/app/repository/DeciderRepository.scala +++ b/app/repository/DeciderRepository.scala @@ -18,7 +18,9 @@ case class Decider( existAdditionalRaces: Boolean, existTextFieldRaces: Boolean, description: Boolean, - maps: List[DeciderMapConfig] + maps: List[DeciderMapConfig], + /** Steam ID владельца: только он может редактировать и удалять десайдера. */ + ownerSteamId: Option[String] = None ) /** @@ -32,6 +34,12 @@ case class Decider( */ object DeciderRepository { + /** + * Владелец по умолчанию. Записи без ownerSteamId (например, из старого + * application.conf) считаются принадлежащими этому Steam ID. + */ + val DefaultOwnerSteamId: String = "76561198137977374" + private def appConfig: Config = ConfigFactory.load() /** Файл, в который админка пишет десайдеров. */ @@ -88,7 +96,11 @@ object DeciderRepository { existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false), existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false), description = Try(d.getBoolean("description")).getOrElse(false), - maps = maps + maps = maps, + ownerSteamId = Try(d.getString("ownerSteamId")).toOption + .map(_.trim) + .filter(_.nonEmpty) + .orElse(Some(DefaultOwnerSteamId)) )) } } @@ -122,6 +134,9 @@ object DeciderRepository { deciders.foreach { d => sb.append(" ").append(quoteKey(d.key)).append(" {\n") sb.append(" name = ").append(quote(d.name)).append('\n') + sb.append(" ownerSteamId = ") + .append(quote(d.ownerSteamId.getOrElse(DefaultOwnerSteamId))) + .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") diff --git a/app/repository/MapImageRepository.scala b/app/repository/MapImageRepository.scala index 743991a..599afc7 100644 --- a/app/repository/MapImageRepository.scala +++ b/app/repository/MapImageRepository.scala @@ -1,19 +1,20 @@ package repository import java.io.File -import java.net.{JarURLConnection, URL} import java.nio.file.StandardCopyOption -import scala.collection.JavaConverters._ + +import com.typesafe.config.ConfigFactory + import scala.util.Try /** Изображение карты, доступное для выбора в админке. */ case class MapImage(name: String, file: String, players: Int) /** - * Список картинок карт из app/assets/images/maps. + * Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`). * - * Картинки читаются из исходников (при запуске через `sbt run`) либо из - * classpath (когда приложение собрано и assets упакованы в jar). + * Каталог читается при каждом обращении, поэтому загруженные через админку + * картинки сразу появляются в галерее без перезапуска приложения. */ object MapImageRepository { @@ -23,36 +24,62 @@ object MapImageRepository { 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 ConfigKey = "maps.imagesDir" - private val ClasspathDirectory = "public/images/maps" + /** Значение по умолчанию, если ключ в конфиге отсутствует. */ + private val DefaultImagesDirectory = "app/assets/images/maps" - /** Каталог, в который сохраняются загруженные картинки (исходники). */ - private val SourceUploadDirectory = "app/assets/images/maps" - - /** Каталоги, из которых Play раздаёт assets в dev-режиме. */ - private val RuntimeUploadDirectory = "target/web/public/main/images/maps" + /** Каталог с картинками карт из конфига. */ + def imagesDirectory: File = { + val configured = Try(ConfigFactory.load().getString(ConfigKey)).toOption + .map(_.trim) + .filter(_.nonEmpty) + .getOrElse(DefaultImagesDirectory) + new File(configured) + } /** Максимальный размер загружаемого файла. */ private val MaxUploadBytes = 10L * 1024 * 1024 /** Все карты, отсортированные по количеству игроков и имени. */ - def all: Seq[MapImage] = - loadFileNames() - .distinct - .map(toMapImage) - .sortBy(image => (groupOrder(image.players), image.name.toLowerCase)) + def all: Seq[MapImage] = { + val directory = imagesDirectory + if (!directory.isDirectory) Seq.empty + else + Option(directory.listFiles()).toSeq.flatten + .filter(_.isFile) + .map(_.getName) + .filter(isMapImage) + .distinct + .map(toMapImage) + .sortBy(image => (groupOrder(image.players), image.name.toLowerCase)) + } + + /** + * Ищет картинку карты по имени файла. + * + * Используется для раздачи картинок через контроллер: имя проверяется так, + * чтобы запрос не мог выйти за пределы каталога из конфига. + */ + def findFile(fileName: String): Option[File] = { + val name = Option(fileName).map(_.trim).getOrElse("") + if (name.isEmpty || name != fileName || !isMapImage(name)) None + else { + val directory = imagesDirectory + val parent = Try(directory.getCanonicalFile).toOption + val candidate = Try(new File(directory, name).getCanonicalFile).toOption + (parent, candidate) match { + case (Some(dir), Some(file)) if file.getParentFile == dir && file.isFile => Some(file) + case _ => None + } + } + } /** * Сохраняет загруженную картинку карты и возвращает её описание. * - * Файл пишется и в исходники (app/assets), и в каталог, который раздаётся - * в dev-режиме (target/web/public/main), чтобы картинка сразу появилась + * Файл пишется в каталог из конфига, поэтому картинка сразу появляется * в галерее без перезапуска приложения. */ def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = { @@ -62,34 +89,23 @@ object MapImageRepository { 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}") - } + val directory = imagesDirectory + Try { + if (!directory.isDirectory) directory.mkdirs() + val fileName = uniqueFileName(safeName, directory) + java.nio.file.Files.copy( + source.toPath, + new File(directory, fileName).toPath, + StandardCopyOption.REPLACE_EXISTING + ) + fileName + }.toEither match { + case Right(fileName) => 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) @@ -105,8 +121,8 @@ object MapImageRepository { } /** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */ - private def uniqueFileName(fileName: String, directories: Seq[File]): String = { - def taken(name: String): Boolean = directories.exists(dir => new File(dir, name).exists()) + private def uniqueFileName(fileName: String, directory: File): String = { + def taken(name: String): Boolean = new File(directory, name).exists() if (!taken(fileName)) fileName else { @@ -120,17 +136,6 @@ object MapImageRepository { } } - 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)) && @@ -138,39 +143,6 @@ object MapImageRepository { 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)) diff --git a/app/views/admin.scala.html b/app/views/admin.scala.html index 7898572..a5b03bd 100644 --- a/app/views/admin.scala.html +++ b/app/views/admin.scala.html @@ -81,9 +81,9 @@
@d.key • maps: @d.maps.size
+@d.key • maps: @d.maps.size • owner: @d.ownerSteamId.getOrElse("-")
} @if(deciders.isEmpty) { @@ -110,6 +110,17 @@Unique decider identifier (letters, digits, hyphen, underscore)
+ +Owner cannot be changed