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 } }