Decider admin
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user