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
+83 -63
View File
@@ -25,6 +25,27 @@ class AdminController @Inject()(
implicit val ec: ExecutionContext = cc.executionContext
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 ----------
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
@@ -89,7 +110,7 @@ class AdminController @Inject()(
def index() = Secured { implicit 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 ----------
@@ -130,16 +151,7 @@ class AdminController @Inject()(
// ---------- API: list all deciders ----------
def decidersList() = Secured { implicit request =>
val list = DeciderRepository.all.map(d => Json.obj(
"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))
))
val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson)
Ok(Json.toJson(list))
}
@@ -147,17 +159,10 @@ class AdminController @Inject()(
def deciderGet(key: String) = Secured { implicit request =>
DeciderRepository.get(key) match {
case Some(d) =>
Ok(Json.obj(
"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))
))
case Some(d) if owns(d, request) =>
Ok(deciderJson(d))
case Some(_) =>
Forbidden(Json.obj("error" -> "You don't own this decider"))
case None =>
NotFound(Json.obj("error" -> s"Decider $key not found"))
}
@@ -167,49 +172,59 @@ class AdminController @Inject()(
def deciderSave() = SecuredJson { implicit request =>
val json = request.body
val steamId = currentSteamId(request)
(json \ "key").asOpt[String] match {
case None =>
BadRequest(Json.obj("error" -> "Missing key"))
case Some(key) =>
Try {
val maps = (json \ "maps").as[List[JsValue]].map { m =>
repository.DeciderMapConfig(
map = (m \ "map").as[String],
description = (m \ "description").asOpt[String]
)
}
repository.Decider(
key = key,
name = (json \ "name").as[String],
rules = (json \ "rules").as[String],
raceCount = (json \ "raceCount").as[Int],
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
description = (json \ "description").as[Boolean],
maps = maps
)
}.toEither match {
case Right(decider) =>
val existed = DeciderRepository.get(decider.key).isDefined
DeciderRepository.save(decider)
DeciderRepository.reload()
if (existed) {
logger.info(
s"Admin ${SecuredActions.steamIdOrUnknown(request)} updated decider '${decider.key}' " +
s"(name='${decider.name}', maps=${decider.maps.size})"
val existing = DeciderRepository.get(key)
if (existing.exists(d => !owns(d, request))) {
Forbidden(Json.obj("error" -> "You don't own this decider"))
} else {
val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("")
val duplicateName = existing.isEmpty && name.nonEmpty &&
DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name))
if (duplicateName) {
Conflict(Json.obj("error" -> s"A decider named '$name' already exists"))
} else {
Try {
val maps = (json \ "maps").as[List[JsValue]].map { m =>
repository.DeciderMapConfig(
map = (m \ "map").as[String],
description = (m \ "description").asOpt[String]
)
}
repository.Decider(
key = key,
name = (json \ "name").as[String],
rules = (json \ "rules").as[String],
raceCount = (json \ "raceCount").as[Int],
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
description = (json \ "description").as[Boolean],
maps = maps,
// Владельца из запроса игнорируем: он либо уже назначен, либо
// им становится автор нового десайдера.
ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId))
)
} else {
logger.info(
s"Admin ${SecuredActions.steamIdOrUnknown(request)} created decider '${decider.key}' " +
s"(name='${decider.name}', maps=${decider.maps.size})"
)
}
Ok(Json.obj("ok" -> true))
}.toEither match {
case Right(decider) =>
DeciderRepository.save(decider)
DeciderRepository.reload()
val action = if (existing.isDefined) "updated" else "created"
logger.info(
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) =>
BadRequest(Json.obj("error" -> s"$error"))
case Left(error) =>
BadRequest(Json.obj("error" -> s"$error"))
}
}
}
}
}
@@ -217,12 +232,17 @@ class AdminController @Inject()(
// ---------- API: delete decider ----------
def deciderDelete(key: String) = SecuredApi { implicit request =>
if (DeciderRepository.delete(key)) {
DeciderRepository.reload()
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} deleted decider '$key'")
Ok(Json.obj("ok" -> true))
} else {
NotFound(Json.obj("error" -> s"Decider $key not found"))
DeciderRepository.get(key) match {
case Some(d) if !owns(d, request) =>
Forbidden(Json.obj("error" -> "You don't own this decider"))
case _ =>
if (DeciderRepository.delete(key)) {
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"))
}
}
}
+38
View File
@@ -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"
}
}
+17 -2
View File
@@ -18,7 +18,9 @@ case class Decider(
existAdditionalRaces: Boolean,
existTextFieldRaces: Boolean,
description: Boolean,
maps: List[DeciderMapConfig]
maps: List[DeciderMapConfig],
/** Steam ID владельца: только он может редактировать и удалять десайдера. */
ownerSteamId: Option[String] = None
)
/**
@@ -32,6 +34,12 @@ case class Decider(
*/
object DeciderRepository {
/**
* Владелец по умолчанию. Записи без ownerSteamId (например, из старого
* application.conf) считаются принадлежащими этому Steam ID.
*/
val DefaultOwnerSteamId: String = "76561198137977374"
private def appConfig: Config = ConfigFactory.load()
/** Файл, в который админка пишет десайдеров. */
@@ -88,7 +96,11 @@ object DeciderRepository {
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).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 =>
sb.append(" ").append(quoteKey(d.key)).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')
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
+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))
+77 -6
View File
@@ -81,9 +81,9 @@
</div>
<div class="list-group" id="deciderList">
@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>
<p class="list-group-item-text decider-key">@d.key &bull; maps: @d.maps.size</p>
<p class="list-group-item-text decider-key">@d.key &bull; maps: @d.maps.size &bull; owner: @d.ownerSteamId.getOrElse("-")</p>
</a>
}
@if(deciders.isEmpty) {
@@ -110,6 +110,17 @@
<label for="formKey">Key</label>
<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" 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 class="form-group">
@@ -218,6 +229,8 @@
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
<script>
var currentDeciderKey = null;
// Steam ID текущего пользователя: владелец нового десайдера.
var currentSteamId = '@request.session.get("steamId").getOrElse("")';
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
// иначе Play отклоняет POST/DELETE с JSON-телом.
@@ -266,7 +279,21 @@
$('#deciderKey').val(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);
$('#formOwner').val(decider.ownerSteamId || currentSteamId);
$('#formName').val(decider.name);
$('#formRules').val(decider.rules);
$('#formRaceCount').val(decider.raceCount);
@@ -288,11 +315,13 @@
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
ensureMapImages();
// Highlight selected in list
// Highlight selected in list (точное совпадение по ключу десайдера)
$('.list-group-item').removeClass('active');
$('.list-group-item').filter(function() {
return $(this).find('.decider-key').text().includes(decider.key);
}).addClass('active');
if (decider.key) {
$('.list-group-item').filter(function() {
return $(this).attr('data-key') === decider.key;
}).addClass('active');
}
}
// 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
$(document).ready(function() {
// Nothing to do on load - user selects from list
$('#mapPickerModal').on('hidden.bs.modal', function() {
pickerTargetRow = null;
});
$('#btnCopyUrl').on('click', copyDeciderUrl);
});
</script>
</body>