163 lines
6.5 KiB
Scala
163 lines
6.5 KiB
Scala
package repository
|
|
|
|
import java.io.File
|
|
import java.nio.file.StandardCopyOption
|
|
|
|
import com.typesafe.config.ConfigFactory
|
|
|
|
import scala.util.Try
|
|
|
|
/** Изображение карты, доступное для выбора в админке. */
|
|
case class MapImage(name: String, file: String, players: Int)
|
|
|
|
/**
|
|
* Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
|
*
|
|
* Каталог читается при каждом обращении, поэтому загруженные через админку
|
|
* картинки сразу появляются в галерее без перезапуска приложения.
|
|
*/
|
|
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
|
|
|
|
/** Ключ конфига с каталогом картинок карт. */
|
|
private val ConfigKey = "maps.imagesDir"
|
|
|
|
/** Значение по умолчанию, если ключ в конфиге отсутствует. */
|
|
private val DefaultImagesDirectory = "app/assets/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] = {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Сохраняет загруженную картинку карты и возвращает её описание.
|
|
*
|
|
* Файл пишется в каталог из конфига, поэтому картинка сразу появляется
|
|
* в галерее без перезапуска приложения.
|
|
*/
|
|
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 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 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, directory: File): String = {
|
|
def taken(name: String): Boolean = new File(directory, 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 isMapImage(fileName: String): Boolean = {
|
|
val lower = fileName.toLowerCase
|
|
ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
|
|
!IgnoredFiles.contains(lower) &&
|
|
IgnoredPattern.findFirstIn(lower).isEmpty
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|