Decider admin access to deciders by steam id

This commit is contained in:
anibus
2026-10-30 20:26:43 +03:00
parent 0212f8b6d0
commit f0ec7bbedb
8 changed files with 1896 additions and 165 deletions
+66 -94
View File
@@ -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))