Decider admin access to deciders by steam id
This commit is contained in:
@@ -25,6 +25,27 @@ class AdminController @Inject()(
|
|||||||
implicit val ec: ExecutionContext = cc.executionContext
|
implicit val ec: ExecutionContext = cc.executionContext
|
||||||
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
|
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 ----------
|
// ---------- Steam OpenID ----------
|
||||||
|
|
||||||
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
|
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
|
||||||
@@ -89,7 +110,7 @@ class AdminController @Inject()(
|
|||||||
|
|
||||||
def index() = Secured { implicit request =>
|
def index() = Secured { implicit request =>
|
||||||
implicit val messages: Messages = messagesApi.preferred(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 ----------
|
// ---------- API: list map images ----------
|
||||||
@@ -130,16 +151,7 @@ class AdminController @Inject()(
|
|||||||
// ---------- API: list all deciders ----------
|
// ---------- API: list all deciders ----------
|
||||||
|
|
||||||
def decidersList() = Secured { implicit request =>
|
def decidersList() = Secured { implicit request =>
|
||||||
val list = DeciderRepository.all.map(d => Json.obj(
|
val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson)
|
||||||
"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))
|
Ok(Json.toJson(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,17 +159,10 @@ class AdminController @Inject()(
|
|||||||
|
|
||||||
def deciderGet(key: String) = Secured { implicit request =>
|
def deciderGet(key: String) = Secured { implicit request =>
|
||||||
DeciderRepository.get(key) match {
|
DeciderRepository.get(key) match {
|
||||||
case Some(d) =>
|
case Some(d) if owns(d, request) =>
|
||||||
Ok(Json.obj(
|
Ok(deciderJson(d))
|
||||||
"key" -> d.key,
|
case Some(_) =>
|
||||||
"name" -> d.name,
|
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||||
"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 =>
|
case None =>
|
||||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||||
}
|
}
|
||||||
@@ -167,49 +172,59 @@ class AdminController @Inject()(
|
|||||||
|
|
||||||
def deciderSave() = SecuredJson { implicit request =>
|
def deciderSave() = SecuredJson { implicit request =>
|
||||||
val json = request.body
|
val json = request.body
|
||||||
|
val steamId = currentSteamId(request)
|
||||||
|
|
||||||
(json \ "key").asOpt[String] match {
|
(json \ "key").asOpt[String] match {
|
||||||
case None =>
|
case None =>
|
||||||
BadRequest(Json.obj("error" -> "Missing key"))
|
BadRequest(Json.obj("error" -> "Missing key"))
|
||||||
|
|
||||||
case Some(key) =>
|
case Some(key) =>
|
||||||
Try {
|
val existing = DeciderRepository.get(key)
|
||||||
val maps = (json \ "maps").as[List[JsValue]].map { m =>
|
if (existing.exists(d => !owns(d, request))) {
|
||||||
repository.DeciderMapConfig(
|
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||||
map = (m \ "map").as[String],
|
} else {
|
||||||
description = (m \ "description").asOpt[String]
|
val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("")
|
||||||
)
|
val duplicateName = existing.isEmpty && name.nonEmpty &&
|
||||||
}
|
DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name))
|
||||||
repository.Decider(
|
if (duplicateName) {
|
||||||
key = key,
|
Conflict(Json.obj("error" -> s"A decider named '$name' already exists"))
|
||||||
name = (json \ "name").as[String],
|
} else {
|
||||||
rules = (json \ "rules").as[String],
|
Try {
|
||||||
raceCount = (json \ "raceCount").as[Int],
|
val maps = (json \ "maps").as[List[JsValue]].map { m =>
|
||||||
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
|
repository.DeciderMapConfig(
|
||||||
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
|
map = (m \ "map").as[String],
|
||||||
description = (json \ "description").as[Boolean],
|
description = (m \ "description").asOpt[String]
|
||||||
maps = maps
|
)
|
||||||
)
|
}
|
||||||
}.toEither match {
|
repository.Decider(
|
||||||
case Right(decider) =>
|
key = key,
|
||||||
val existed = DeciderRepository.get(decider.key).isDefined
|
name = (json \ "name").as[String],
|
||||||
DeciderRepository.save(decider)
|
rules = (json \ "rules").as[String],
|
||||||
DeciderRepository.reload()
|
raceCount = (json \ "raceCount").as[Int],
|
||||||
if (existed) {
|
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
|
||||||
logger.info(
|
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
|
||||||
s"Admin ${SecuredActions.steamIdOrUnknown(request)} updated decider '${decider.key}' " +
|
description = (json \ "description").as[Boolean],
|
||||||
s"(name='${decider.name}', maps=${decider.maps.size})"
|
maps = maps,
|
||||||
|
// Владельца из запроса игнорируем: он либо уже назначен, либо
|
||||||
|
// им становится автор нового десайдера.
|
||||||
|
ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId))
|
||||||
)
|
)
|
||||||
} else {
|
}.toEither match {
|
||||||
logger.info(
|
case Right(decider) =>
|
||||||
s"Admin ${SecuredActions.steamIdOrUnknown(request)} created decider '${decider.key}' " +
|
DeciderRepository.save(decider)
|
||||||
s"(name='${decider.name}', maps=${decider.maps.size})"
|
DeciderRepository.reload()
|
||||||
)
|
val action = if (existing.isDefined) "updated" else "created"
|
||||||
}
|
logger.info(
|
||||||
Ok(Json.obj("ok" -> true))
|
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) =>
|
case Left(error) =>
|
||||||
BadRequest(Json.obj("error" -> s"$error"))
|
BadRequest(Json.obj("error" -> s"$error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,12 +232,17 @@ class AdminController @Inject()(
|
|||||||
// ---------- API: delete decider ----------
|
// ---------- API: delete decider ----------
|
||||||
|
|
||||||
def deciderDelete(key: String) = SecuredApi { implicit request =>
|
def deciderDelete(key: String) = SecuredApi { implicit request =>
|
||||||
if (DeciderRepository.delete(key)) {
|
DeciderRepository.get(key) match {
|
||||||
DeciderRepository.reload()
|
case Some(d) if !owns(d, request) =>
|
||||||
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} deleted decider '$key'")
|
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||||
Ok(Json.obj("ok" -> true))
|
case _ =>
|
||||||
} else {
|
if (DeciderRepository.delete(key)) {
|
||||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ case class Decider(
|
|||||||
existAdditionalRaces: Boolean,
|
existAdditionalRaces: Boolean,
|
||||||
existTextFieldRaces: Boolean,
|
existTextFieldRaces: Boolean,
|
||||||
description: Boolean,
|
description: Boolean,
|
||||||
maps: List[DeciderMapConfig]
|
maps: List[DeciderMapConfig],
|
||||||
|
/** Steam ID владельца: только он может редактировать и удалять десайдера. */
|
||||||
|
ownerSteamId: Option[String] = None
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +34,12 @@ case class Decider(
|
|||||||
*/
|
*/
|
||||||
object DeciderRepository {
|
object DeciderRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Владелец по умолчанию. Записи без ownerSteamId (например, из старого
|
||||||
|
* application.conf) считаются принадлежащими этому Steam ID.
|
||||||
|
*/
|
||||||
|
val DefaultOwnerSteamId: String = "76561198137977374"
|
||||||
|
|
||||||
private def appConfig: Config = ConfigFactory.load()
|
private def appConfig: Config = ConfigFactory.load()
|
||||||
|
|
||||||
/** Файл, в который админка пишет десайдеров. */
|
/** Файл, в который админка пишет десайдеров. */
|
||||||
@@ -88,7 +96,11 @@ object DeciderRepository {
|
|||||||
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
|
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
|
||||||
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false),
|
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false),
|
||||||
description = Try(d.getBoolean("description")).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 =>
|
deciders.foreach { d =>
|
||||||
sb.append(" ").append(quoteKey(d.key)).append(" {\n")
|
sb.append(" ").append(quoteKey(d.key)).append(" {\n")
|
||||||
sb.append(" name = ").append(quote(d.name)).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')
|
sb.append(" raceCount = ").append(d.raceCount).append('\n')
|
||||||
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
|
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
|
||||||
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
|
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.{JarURLConnection, URL}
|
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
import scala.collection.JavaConverters._
|
|
||||||
|
import com.typesafe.config.ConfigFactory
|
||||||
|
|
||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
|
|
||||||
/** Изображение карты, доступное для выбора в админке. */
|
/** Изображение карты, доступное для выбора в админке. */
|
||||||
case class MapImage(name: String, file: String, players: Int)
|
case class MapImage(name: String, file: String, players: Int)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список картинок карт из app/assets/images/maps.
|
* Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
||||||
*
|
*
|
||||||
* Картинки читаются из исходников (при запуске через `sbt run`) либо из
|
* Каталог читается при каждом обращении, поэтому загруженные через админку
|
||||||
* classpath (когда приложение собрано и assets упакованы в jar).
|
* картинки сразу появляются в галерее без перезапуска приложения.
|
||||||
*/
|
*/
|
||||||
object MapImageRepository {
|
object MapImageRepository {
|
||||||
|
|
||||||
@@ -23,36 +24,62 @@ object MapImageRepository {
|
|||||||
private val IgnoredFiles = Set("default.jpg")
|
private val IgnoredFiles = Set("default.jpg")
|
||||||
private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r
|
private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r
|
||||||
|
|
||||||
/** Каталоги с картинками: исходники и результат сборки assets. */
|
/** Ключ конфига с каталогом картинок карт. */
|
||||||
private val MapDirectories = Seq(
|
private val ConfigKey = "maps.imagesDir"
|
||||||
"app/assets/images/maps",
|
|
||||||
"target/web/public/main/images/maps",
|
|
||||||
"public/images/maps"
|
|
||||||
)
|
|
||||||
|
|
||||||
private val ClasspathDirectory = "public/images/maps"
|
/** Значение по умолчанию, если ключ в конфиге отсутствует. */
|
||||||
|
private val DefaultImagesDirectory = "app/assets/images/maps"
|
||||||
|
|
||||||
/** Каталог, в который сохраняются загруженные картинки (исходники). */
|
/** Каталог с картинками карт из конфига. */
|
||||||
private val SourceUploadDirectory = "app/assets/images/maps"
|
def imagesDirectory: File = {
|
||||||
|
val configured = Try(ConfigFactory.load().getString(ConfigKey)).toOption
|
||||||
/** Каталоги, из которых Play раздаёт assets в dev-режиме. */
|
.map(_.trim)
|
||||||
private val RuntimeUploadDirectory = "target/web/public/main/images/maps"
|
.filter(_.nonEmpty)
|
||||||
|
.getOrElse(DefaultImagesDirectory)
|
||||||
|
new File(configured)
|
||||||
|
}
|
||||||
|
|
||||||
/** Максимальный размер загружаемого файла. */
|
/** Максимальный размер загружаемого файла. */
|
||||||
private val MaxUploadBytes = 10L * 1024 * 1024
|
private val MaxUploadBytes = 10L * 1024 * 1024
|
||||||
|
|
||||||
/** Все карты, отсортированные по количеству игроков и имени. */
|
/** Все карты, отсортированные по количеству игроков и имени. */
|
||||||
def all: Seq[MapImage] =
|
def all: Seq[MapImage] = {
|
||||||
loadFileNames()
|
val directory = imagesDirectory
|
||||||
.distinct
|
if (!directory.isDirectory) Seq.empty
|
||||||
.map(toMapImage)
|
else
|
||||||
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
|
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] = {
|
def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = {
|
||||||
@@ -62,34 +89,23 @@ object MapImageRepository {
|
|||||||
else sanitizeFileName(rawFileName) match {
|
else sanitizeFileName(rawFileName) match {
|
||||||
case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)")
|
case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)")
|
||||||
case Some(safeName) =>
|
case Some(safeName) =>
|
||||||
val directories = uploadDirectories()
|
val directory = imagesDirectory
|
||||||
if (directories.isEmpty) Left("Map images directory not found")
|
Try {
|
||||||
else {
|
if (!directory.isDirectory) directory.mkdirs()
|
||||||
val fileName = uniqueFileName(safeName, directories)
|
val fileName = uniqueFileName(safeName, directory)
|
||||||
Try {
|
java.nio.file.Files.copy(
|
||||||
directories.foreach { dir =>
|
source.toPath,
|
||||||
java.nio.file.Files.copy(
|
new File(directory, fileName).toPath,
|
||||||
source.toPath,
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
new File(dir, fileName).toPath,
|
)
|
||||||
StandardCopyOption.REPLACE_EXISTING
|
fileName
|
||||||
)
|
}.toEither match {
|
||||||
}
|
case Right(fileName) => Right(toMapImage(fileName))
|
||||||
}.toEither match {
|
case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
|
||||||
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] = {
|
private def sanitizeFileName(rawFileName: String): Option[String] = {
|
||||||
val name = Option(rawFileName)
|
val name = Option(rawFileName)
|
||||||
@@ -105,8 +121,8 @@ object MapImageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
|
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
|
||||||
private def uniqueFileName(fileName: String, directories: Seq[File]): String = {
|
private def uniqueFileName(fileName: String, directory: File): String = {
|
||||||
def taken(name: String): Boolean = directories.exists(dir => new File(dir, name).exists())
|
def taken(name: String): Boolean = new File(directory, name).exists()
|
||||||
|
|
||||||
if (!taken(fileName)) fileName
|
if (!taken(fileName)) fileName
|
||||||
else {
|
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 = {
|
private def isMapImage(fileName: String): Boolean = {
|
||||||
val lower = fileName.toLowerCase
|
val lower = fileName.toLowerCase
|
||||||
ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
|
ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
|
||||||
@@ -138,39 +143,6 @@ object MapImageRepository {
|
|||||||
IgnoredPattern.findFirstIn(lower).isEmpty
|
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 = {
|
private def toMapImage(fileName: String): MapImage = {
|
||||||
val baseName = fileName.substring(0, fileName.lastIndexOf('.'))
|
val baseName = fileName.substring(0, fileName.lastIndexOf('.'))
|
||||||
MapImage(baseName, fileName, playersFrom(fileName))
|
MapImage(baseName, fileName, playersFrom(fileName))
|
||||||
|
|||||||
@@ -81,9 +81,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="list-group" id="deciderList">
|
<div class="list-group" id="deciderList">
|
||||||
@for(d <- deciders) {
|
@for(d <- deciders) {
|
||||||
<a class="list-group-item" onclick="openDecider('@d.key')">
|
<a class="list-group-item" data-key="@d.key" onclick="openDecider('@d.key')">
|
||||||
<h4 class="list-group-item-heading">@d.name</h4>
|
<h4 class="list-group-item-heading">@d.name</h4>
|
||||||
<p class="list-group-item-text decider-key">@d.key • maps: @d.maps.size</p>
|
<p class="list-group-item-text decider-key">@d.key • maps: @d.maps.size • owner: @d.ownerSteamId.getOrElse("-")</p>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if(deciders.isEmpty) {
|
@if(deciders.isEmpty) {
|
||||||
@@ -110,6 +110,17 @@
|
|||||||
<label for="formKey">Key</label>
|
<label for="formKey">Key</label>
|
||||||
<input type="text" class="form-control" id="formKey" placeholder="e.g. dawn_of_war">
|
<input type="text" class="form-control" id="formKey" placeholder="e.g. dawn_of_war">
|
||||||
<p class="help-block">Unique decider identifier (letters, digits, hyphen, underscore)</p>
|
<p class="help-block">Unique decider identifier (letters, digits, hyphen, underscore)</p>
|
||||||
|
<p class="help-block" id="keyUrlBlock" style="display: none;">
|
||||||
|
URL: <a id="formKeyUrl" href="#" target="_blank" rel="noopener"></a>
|
||||||
|
<button type="button" class="btn btn-xs btn-default" id="btnCopyUrl"
|
||||||
|
title="Copy URL to clipboard">Copy</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formOwner">Owner Steam ID</label>
|
||||||
|
<input type="text" class="form-control" id="formOwner" readonly>
|
||||||
|
<p class="help-block">Owner cannot be changed</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -218,6 +229,8 @@
|
|||||||
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
|
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
|
||||||
<script>
|
<script>
|
||||||
var currentDeciderKey = null;
|
var currentDeciderKey = null;
|
||||||
|
// Steam ID текущего пользователя: владелец нового десайдера.
|
||||||
|
var currentSteamId = '@request.session.get("steamId").getOrElse("")';
|
||||||
|
|
||||||
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
|
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
|
||||||
// иначе Play отклоняет POST/DELETE с JSON-телом.
|
// иначе Play отклоняет POST/DELETE с JSON-телом.
|
||||||
@@ -266,7 +279,21 @@
|
|||||||
|
|
||||||
$('#deciderKey').val(decider.key);
|
$('#deciderKey').val(decider.key);
|
||||||
$('#formKey').val(decider.key).prop('readonly', !!decider.key);
|
$('#formKey').val(decider.key).prop('readonly', !!decider.key);
|
||||||
|
|
||||||
|
// Ссылка на десайдер: показываем только для уже созданных.
|
||||||
|
if (decider.key) {
|
||||||
|
var deciderPath = '/decider/' + encodeURIComponent(decider.key);
|
||||||
|
$('#formKeyUrl')
|
||||||
|
.attr('href', deciderPath)
|
||||||
|
.text(window.location.origin + deciderPath);
|
||||||
|
$('#keyUrlBlock').show();
|
||||||
|
} else {
|
||||||
|
$('#formKeyUrl').attr('href', '#').text('');
|
||||||
|
$('#keyUrlBlock').hide();
|
||||||
|
}
|
||||||
|
|
||||||
$('#btnDelete').toggle(!!decider.key);
|
$('#btnDelete').toggle(!!decider.key);
|
||||||
|
$('#formOwner').val(decider.ownerSteamId || currentSteamId);
|
||||||
$('#formName').val(decider.name);
|
$('#formName').val(decider.name);
|
||||||
$('#formRules').val(decider.rules);
|
$('#formRules').val(decider.rules);
|
||||||
$('#formRaceCount').val(decider.raceCount);
|
$('#formRaceCount').val(decider.raceCount);
|
||||||
@@ -288,11 +315,13 @@
|
|||||||
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
|
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
|
||||||
ensureMapImages();
|
ensureMapImages();
|
||||||
|
|
||||||
// Highlight selected in list
|
// Highlight selected in list (точное совпадение по ключу десайдера)
|
||||||
$('.list-group-item').removeClass('active');
|
$('.list-group-item').removeClass('active');
|
||||||
$('.list-group-item').filter(function() {
|
if (decider.key) {
|
||||||
return $(this).find('.decider-key').text().includes(decider.key);
|
$('.list-group-item').filter(function() {
|
||||||
}).addClass('active');
|
return $(this).attr('data-key') === decider.key;
|
||||||
|
}).addClass('active');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close edit panel
|
// Close edit panel
|
||||||
@@ -644,12 +673,54 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Копирование ссылки на десайдер в буфер обмена.
|
||||||
|
// navigator.clipboard доступен только в защищённом контексте (https/localhost),
|
||||||
|
// поэтому есть запасной вариант через скрытую textarea.
|
||||||
|
function copyDeciderUrl() {
|
||||||
|
var url = $('#formKeyUrl').text();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
function done(ok) {
|
||||||
|
var btn = $('#btnCopyUrl');
|
||||||
|
btn.text(ok ? 'Copied!' : 'Copy failed');
|
||||||
|
setTimeout(function() { btn.text('Copy'); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy() {
|
||||||
|
var textarea = $('<textarea>').val(url).css({
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
opacity: 0
|
||||||
|
}).appendTo('body');
|
||||||
|
textarea[0].select();
|
||||||
|
var ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch (e) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
textarea.remove();
|
||||||
|
done(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(url).then(function() {
|
||||||
|
done(true);
|
||||||
|
}, fallbackCopy);
|
||||||
|
} else {
|
||||||
|
fallbackCopy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize: if there's only one decider, open it automatically
|
// Initialize: if there's only one decider, open it automatically
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
// Nothing to do on load - user selects from list
|
// Nothing to do on load - user selects from list
|
||||||
$('#mapPickerModal').on('hidden.bs.modal', function() {
|
$('#mapPickerModal').on('hidden.bs.modal', function() {
|
||||||
pickerTargetRow = null;
|
pickerTargetRow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#btnCopyUrl').on('click', copyDeciderUrl);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ admin {
|
|||||||
decidersFile = "conf/deciders.conf"
|
decidersFile = "conf/deciders.conf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Каталог с картинками карт: из него читается галерея в админке и в него
|
||||||
|
# сохраняются загруженные картинки. Путь может быть абсолютным или
|
||||||
|
# относительным (от рабочего каталога приложения).
|
||||||
|
maps {
|
||||||
|
imagesDir = "app/assets/images/maps"
|
||||||
|
}
|
||||||
|
|
||||||
deciders{
|
deciders{
|
||||||
classic{
|
classic{
|
||||||
maps = [["2p_battle_marshes"],
|
maps = [["2p_battle_marshes"],
|
||||||
|
|||||||
+1604
File diff suppressed because it is too large
Load Diff
@@ -21,5 +21,9 @@ DELETE /decider/admin/deciders/:key controllers.AdminController.deciderDel
|
|||||||
|
|
||||||
GET /decider/:name controllers.HomeController.index(name)
|
GET /decider/:name controllers.HomeController.index(name)
|
||||||
|
|
||||||
|
# Картинки карт из каталога maps.imagesDir.
|
||||||
|
# ВАЖНО: маршрут должен идти ДО /assets/*file, иначе его перехватит Assets.
|
||||||
|
GET /assets/images/maps/:file controllers.MapImageController.image(file)
|
||||||
|
|
||||||
# Map static resources from the /public folder to the /assets URL path
|
# Map static resources from the /public folder to the /assets URL path
|
||||||
GET /assets/*file controllers.Assets.at(path="/public", file)
|
GET /assets/*file controllers.Assets.at(path="/public", file)
|
||||||
|
|||||||
Reference in New Issue
Block a user