546 lines
No EOL
20 KiB
C#
546 lines
No EOL
20 KiB
C#
using System.Net.Mime;
|
|
using System.Security.Claims;
|
|
using AutoMapper;
|
|
using LANCommander.SDK.Enums;
|
|
using LANCommander.SDK.Services;
|
|
using LANCommander.Server.Data.Models;
|
|
using LANCommander.Server.ImportExport;
|
|
using LANCommander.Server.Services;
|
|
using LANCommander.Server.Services.Extensions;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
using ZiggyCreatures.Caching.Fusion;
|
|
|
|
namespace LANCommander.Server.Endpoints;
|
|
|
|
public static class GameEndpoints
|
|
{
|
|
public static void MapGameEndpoints(this IEndpointRouteBuilder routes)
|
|
{
|
|
var group = routes.MapGroup("/api/Games").RequireAuthorization();
|
|
|
|
group.MapGet("/", GetAsync);
|
|
group.MapGet("/{id:guid}", GetByIdAsync);
|
|
group.MapGet("/{id:guid}/Manifest", GetManifestByIdAsync);
|
|
group.MapGet("/{id:guid}/Actions", GetActionsByIdAsync);
|
|
group.MapGet("/{id:guid}/Addons", GetAddonsByIdAsync);
|
|
group.MapGet("/{id:guid}/Tools", GetToolsByIdAsync);
|
|
group.MapGet("/{id:guid}/Scripts", GetScriptsByIdAsync);
|
|
group.MapGet("/{id:guid}/Started", StartedAsync);
|
|
group.MapGet("/{id:guid}/Stopped", StoppedAsync);
|
|
group.MapGet("/{id:guid}/Updates", GetUpdatesAsync);
|
|
group.MapGet("/{id:guid}/CheckForUpdate", CheckForUpdateAsync);
|
|
group.MapGet("/{id:guid}/Download", DownloadAsync).AllowAnonymous();
|
|
group.MapGet("/{id:guid}/Import", ImportAsync).RequireAuthorization(RoleService.AdministratorRoleName);
|
|
group.MapPost("/UploadArchive", UploadArchiveAsync).RequireAuthorization(RoleService.AdministratorRoleName);
|
|
}
|
|
|
|
internal static async Task<IResult> GetAsync(
|
|
[FromServices] UserService userService,
|
|
[FromServices] GameService gameService,
|
|
[FromServices] LibraryService libraryService,
|
|
[FromServices] IOptions<Settings.Settings> settings,
|
|
[FromServices] IFusionCache cache,
|
|
[FromServices] IMapper mapper,
|
|
[FromServices] ILogger<Game> logger,
|
|
ClaimsPrincipal userPrincipal)
|
|
{
|
|
var user = await userService.GetAsync(userPrincipal?.Identity?.Name ?? "");
|
|
var userLibrary = await libraryService.GetByUserIdAsync(user.Id);
|
|
|
|
var mappedGames = await cache.GetOrSetAsync<IEnumerable<SDK.Models.Game>>("Games", async _ =>
|
|
{
|
|
logger.LogDebug("Mapped games cache is empty, repopulating...");
|
|
|
|
var games = await gameService
|
|
.AsNoTracking()
|
|
.AsSplitQuery()
|
|
.GetAsync();
|
|
|
|
return mapper.Map<IEnumerable<SDK.Models.Game>>(games);
|
|
}, TimeSpan.MaxValue, tags: ["Games"]);
|
|
|
|
foreach (var mappedGame in mappedGames)
|
|
{
|
|
if (userLibrary.Games != null)
|
|
mappedGame.InLibrary = userLibrary.Games.Any(g => g.Id == mappedGame.Id);
|
|
}
|
|
|
|
if (settings.Value.Server.Roles.RestrictGamesByCollection && !userPrincipal.IsInRole(RoleService.AdministratorRoleName))
|
|
{
|
|
var roles = await userService.GetRolesAsync(user);
|
|
|
|
var accessibleCollectionIds = roles.SelectMany(r => r.Collections.Select(c => c.Id)).Distinct();
|
|
|
|
var accessibleGames = mappedGames.Where(g => g.Collections.Any(c => accessibleCollectionIds.Contains(c.Id)));
|
|
|
|
foreach (var game in accessibleGames)
|
|
{
|
|
game.Collections = game.Collections.Where(c => accessibleCollectionIds.Contains(c.Id));
|
|
}
|
|
|
|
return TypedResults.Ok(accessibleGames);
|
|
}
|
|
|
|
return TypedResults.Ok(mappedGames);
|
|
}
|
|
|
|
internal static async Task<IResult> GetByIdAsync(
|
|
[FromServices] GameService gameService,
|
|
[FromServices] IFusionCache cache,
|
|
[FromServices] IMapper mapper,
|
|
Guid id)
|
|
{
|
|
var game = await cache.GetOrSetAsync<SDK.Models.Manifest.Game>($"Games/{id}", async _ =>
|
|
{
|
|
var result = await gameService
|
|
.Include(g => g.Actions)
|
|
.Include(g => g.Archives)
|
|
.Include(g => g.BaseGame)
|
|
.Include(g => g.Categories)
|
|
.Include(g => g.Collections)
|
|
.Include(g => g.DependentGames)
|
|
.Include(g => g.Developers)
|
|
.Include(g => g.Engine)
|
|
.Include(g => g.Genres)
|
|
.Include(g => g.Media)
|
|
.Include(g => g.MultiplayerModes)
|
|
.Include(g => g.Platforms)
|
|
.Include(g => g.Publishers)
|
|
.Query(q => q.Include(d => d.Redistributables).ThenInclude(r => r.Scripts))
|
|
.Query(q => q.Include(d => d.Redistributables).ThenInclude(r => r.Archives))
|
|
.Include(g => g.Scripts)
|
|
.Include(g => g.Tags)
|
|
.AsNoTracking()
|
|
.AsSplitQuery()
|
|
.GetAsync(id);
|
|
|
|
return mapper.Map<SDK.Models.Manifest.Game>(result);
|
|
}, TimeSpan.MaxValue, tags: ["Games", $"Games/{id}"]);
|
|
|
|
if (game != null)
|
|
return TypedResults.Ok(game);
|
|
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
internal static async Task<IResult> GetManifestByIdAsync(
|
|
[FromServices] GameService gameService,
|
|
[FromServices] IFusionCache cache,
|
|
Guid id)
|
|
{
|
|
var manifest = await cache
|
|
.GetOrSetAsync<SDK.Models.Manifest.Game>(
|
|
$"Game/{id}/Manifest",
|
|
async _ => await gameService.GetManifestAsync(id),
|
|
TimeSpan.MaxValue,
|
|
tags: ["Games", $"Games/{id}"]);
|
|
|
|
return TypedResults.Ok(manifest);
|
|
}
|
|
|
|
internal static async Task<IResult> GetActionsByIdAsync(
|
|
[FromServices] UserService userService,
|
|
[FromServices] GameService gameService,
|
|
[FromServices] LibraryService libraryService,
|
|
[FromServices] SettingsProvider<Settings.Settings> settingsProvider,
|
|
[FromServices] IFusionCache cache,
|
|
[FromServices] IMapper mapper,
|
|
[FromServices] ILogger<Game> logger,
|
|
ClaimsPrincipal userPrincipal,
|
|
Guid id)
|
|
{
|
|
var actions = await cache.GetOrSetAsync<IEnumerable<SDK.Models.Action>>($"Games/{id}/Actions", async _ =>
|
|
{
|
|
var game = await gameService
|
|
.Query(q =>
|
|
{
|
|
return q
|
|
.Include(g => g.Actions)
|
|
.Include(g => g.DependentGames)
|
|
.ThenInclude(dg => dg.Actions)
|
|
.Include(g => g.Servers)
|
|
.ThenInclude(s => s.Actions);
|
|
})
|
|
.AsNoTracking()
|
|
.AsSplitQuery()
|
|
.GetAsync(id);
|
|
|
|
var dataActions = new List<Data.Models.Action>();
|
|
|
|
dataActions.AddRange(game.Actions.OrderBy(a => a.SortOrder));
|
|
dataActions.AddRange(game.DependentGames.Where(dg => dg.Type == GameType.Expansion || dg.Type == GameType.Mod).OrderBy(dg => String.IsNullOrWhiteSpace(dg.SortTitle) ? dg.Title : dg.SortTitle).SelectMany(dg => dg.Actions.OrderBy(a => a.SortOrder)));
|
|
|
|
var mappedActions = mapper.Map<List<SDK.Models.Action>>(dataActions);
|
|
|
|
foreach (var server in game.Servers)
|
|
{
|
|
foreach (var serverAction in server.Actions)
|
|
{
|
|
var mappedAction = mapper.Map<SDK.Models.Action>(serverAction);
|
|
|
|
// Server actions are attached to the server, not the game, so their GameId is
|
|
// empty. Stamp the owning game's id so the launcher associates the action with
|
|
// the installed game instead of filtering it out.
|
|
mappedAction.GameId = game.Id;
|
|
|
|
if (!String.IsNullOrWhiteSpace(server.Host))
|
|
mappedAction.Variables["ServerHost"] = server.Host;
|
|
|
|
if (server.Port > 0)
|
|
mappedAction.Variables["ServerPort"] = server.Port.ToString();
|
|
|
|
mappedActions.Add(mappedAction);
|
|
}
|
|
}
|
|
|
|
return mappedActions;
|
|
}, tags: ["Games", $"Games/{id}"]);
|
|
|
|
return TypedResults.Ok(actions);
|
|
}
|
|
|
|
internal static async Task<IResult> GetAddonsByIdAsync(
|
|
[FromServices] GameService gameService,
|
|
[FromServices] IFusionCache cache,
|
|
[FromServices] IMapper mapper,
|
|
Guid id)
|
|
{
|
|
var addons = await cache.GetOrSetAsync($"Games/{id}/Addons", async _ =>
|
|
{
|
|
var results = await gameService
|
|
.Include(g => g.Archives)
|
|
.AsSplitQuery()
|
|
.AsNoTracking()
|
|
.GetAsync(g => g.BaseGameId == id && (g.Type == GameType.Expansion || g.Type == GameType.Mod));
|
|
|
|
return mapper.Map<IEnumerable<SDK.Models.Game>>(results);
|
|
}, tags: ["Games", $"Games/{id}"]);
|
|
|
|
return TypedResults.Ok(addons);
|
|
}
|
|
|
|
internal static async Task<IResult> GetToolsByIdAsync(
|
|
[FromServices] ToolService toolService,
|
|
[FromServices] IFusionCache cache,
|
|
[FromServices] IMapper mapper,
|
|
Guid id)
|
|
{
|
|
var tools = await cache.GetOrSetAsync($"Games/{id}/Tools", async _ =>
|
|
{
|
|
var results = await toolService
|
|
.Include(t => t.Archives)
|
|
.AsSplitQuery()
|
|
.AsNoTracking()
|
|
.GetAsync(t => t.Games.Any(g => g.Id == id));
|
|
|
|
return mapper.Map<IEnumerable<SDK.Models.Tool>>(results);
|
|
}, tags: ["Tools", "Games", $"Games/{id}"]);
|
|
|
|
return TypedResults.Ok(tools);
|
|
}
|
|
|
|
internal static async Task<IResult> GetScriptsByIdAsync(
|
|
[FromServices] ScriptService scriptService,
|
|
[FromServices] IFusionCache cache,
|
|
[FromServices] IMapper mapper,
|
|
Guid id)
|
|
{
|
|
var scripts = await cache.GetOrSetAsync($"Games/{id}/Scripts", async _ =>
|
|
{
|
|
var results = await scriptService
|
|
.AsSplitQuery()
|
|
.AsNoTracking()
|
|
.GetAsync(s => s.GameId == id && s.Type != SDK.Enums.ScriptType.Package);
|
|
|
|
return mapper.Map<IEnumerable<SDK.Models.Script>>(results);
|
|
}, tags: ["Scripts", $"Games/{id}/Scripts", "Games", $"Games/{id}"]);
|
|
|
|
return TypedResults.Ok(scripts);
|
|
}
|
|
|
|
internal static async Task<IResult> StartedAsync(
|
|
[FromServices] UserService userService,
|
|
[FromServices] GameService gameService,
|
|
[FromServices] PlaySessionService playSessionService,
|
|
[FromServices] ServerManager serverManager,
|
|
[FromServices] IServiceScopeFactory scopeFactory,
|
|
ClaimsPrincipal userPrincipal,
|
|
Guid id)
|
|
{
|
|
var user = await userService.GetAsync(userPrincipal?.Identity?.Name);
|
|
var game = await gameService.GetAsync(id);
|
|
|
|
if (game == null || user == null)
|
|
return TypedResults.BadRequest();
|
|
|
|
#region Start recording play session
|
|
var activeSessions = await playSessionService
|
|
.Include(ps => ps.Game)
|
|
.GetAsync(ps => ps.UserId == user.Id && ps.End == null);
|
|
|
|
foreach (var activeSession in activeSessions)
|
|
await playSessionService.EndSessionAsync(game.Id, activeSession.UserId);
|
|
|
|
await playSessionService.StartSessionAsync(game.Id, user.Id);
|
|
#endregion
|
|
|
|
#region Autostart Servers
|
|
await serverManager.AutostartAsync(game.Id, ServerAutostartMethod.OnPlayerActivity);
|
|
#endregion
|
|
|
|
#region Run server scripts
|
|
// Fire-and-forget: GameStarted scripts can launch long-lived processes that would
|
|
// otherwise block this request for the process's lifetime (the autostart launches above
|
|
// are detached for the same reason). Run them in their own scope so they survive request
|
|
// scope disposal.
|
|
RunServerScriptsAsync(scopeFactory, game.Id, user.Id, ScriptType.GameStarted);
|
|
#endregion
|
|
|
|
return TypedResults.Ok();
|
|
}
|
|
|
|
private static void RunServerScriptsAsync(
|
|
IServiceScopeFactory scopeFactory,
|
|
Guid gameId,
|
|
Guid userId,
|
|
ScriptType scriptType)
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
using var scope = scopeFactory.CreateScope();
|
|
var serverService = scope.ServiceProvider.GetRequiredService<ServerService>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Game>>();
|
|
|
|
try
|
|
{
|
|
var servers = await serverService.GetAsync(s => s.GameId == gameId);
|
|
|
|
foreach (var server in servers)
|
|
{
|
|
if (scriptType == ScriptType.GameStarted)
|
|
await serverService.RunGameStartedScriptsAsync(server.Id, userId);
|
|
else if (scriptType == ScriptType.GameStopped)
|
|
await serverService.RunGameStoppedScriptsAsync(server.Id, userId);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Server scripts could not run");
|
|
}
|
|
});
|
|
}
|
|
|
|
internal static async Task<IResult> StoppedAsync(
|
|
[FromServices] UserService userService,
|
|
[FromServices] GameService gameService,
|
|
[FromServices] PlaySessionService playSessionService,
|
|
[FromServices] ServerManager serverManager,
|
|
[FromServices] IServiceScopeFactory scopeFactory,
|
|
ClaimsPrincipal userPrincipal,
|
|
Guid id)
|
|
{
|
|
var user = await userService.GetAsync(userPrincipal?.Identity?.Name);
|
|
var game = await gameService.GetAsync(id);
|
|
|
|
if (game == null || user == null)
|
|
return TypedResults.BadRequest();
|
|
|
|
await playSessionService.EndSessionAsync(game.Id, user.Id);
|
|
|
|
#region Autostop Servers
|
|
// Once the last player has stopped playing, debounce the stop so a quick relaunch
|
|
// doesn't cause the servers to thrash between stopped and started.
|
|
var activeSessions = await playSessionService
|
|
.GetAsync(ps => ps.GameId == game.Id && ps.End == null);
|
|
|
|
if (!activeSessions.Any())
|
|
serverManager.ScheduleStop(game.Id);
|
|
#endregion
|
|
|
|
#region Run server scripts
|
|
// Fire-and-forget for the same reason as the started path: GameStopped scripts can block
|
|
// on long-lived processes. Run them in their own scope so they survive request disposal.
|
|
RunServerScriptsAsync(scopeFactory, game.Id, user.Id, ScriptType.GameStopped);
|
|
#endregion
|
|
|
|
return TypedResults.Ok();
|
|
}
|
|
|
|
internal static async Task<IResult> GetUpdatesAsync(
|
|
[FromServices] GameService gameService,
|
|
[FromServices] IMapper mapper,
|
|
[FromServices] ILogger<Game> logger,
|
|
Guid id,
|
|
string version)
|
|
{
|
|
try
|
|
{
|
|
var archives = await gameService.GetUpdatesAsync(id, version);
|
|
var mapped = mapper.Map<IEnumerable<SDK.Models.Archive>>(archives);
|
|
|
|
return TypedResults.Ok(mapped);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Could not get updates for game {GameId}", id);
|
|
return TypedResults.Ok(Enumerable.Empty<SDK.Models.Archive>());
|
|
}
|
|
}
|
|
|
|
internal static async Task<IResult> CheckForUpdateAsync(
|
|
[FromServices] GameService gameService,
|
|
[FromServices] ILogger<Game> logger,
|
|
Guid id,
|
|
string version)
|
|
{
|
|
try
|
|
{
|
|
var currentVersion = await gameService.GetVersionAsync(id);
|
|
|
|
return TypedResults.Ok(version != currentVersion);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Version could not be found for game {GameId}", id);
|
|
}
|
|
|
|
return TypedResults.Ok(false);
|
|
}
|
|
|
|
internal static async Task<IResult> DownloadAsync(
|
|
[FromServices] GameService gameService,
|
|
[FromServices] ArchiveService archiveService,
|
|
[FromServices] IOptions<Settings.Settings> settings,
|
|
[FromServices] ILogger<Game> logger,
|
|
ClaimsPrincipal userPrincipal,
|
|
Guid id)
|
|
{
|
|
if (!settings.Value.Server.Archives.AllowInsecureDownloads &&
|
|
!(userPrincipal?.Identity?.IsAuthenticated ?? false))
|
|
{
|
|
logger.LogError("User is not authorized to download game with ID {GameId}", id);
|
|
|
|
return TypedResults.Unauthorized();
|
|
}
|
|
|
|
var game = await gameService
|
|
.Include(g => g.Archives)
|
|
.GetAsync(id);
|
|
|
|
if (game == null)
|
|
{
|
|
logger.LogError("Game with ID {GameId} could not be found", id);
|
|
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
if (!game.Archives.Any())
|
|
{
|
|
logger.LogError("No archives found for game with ID {GameId}", id);
|
|
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var archive = await gameService.GetLatestArchiveAsync(id);
|
|
var path = await archiveService.GetArchiveFileLocationAsync(archive);
|
|
|
|
if (!File.Exists(path))
|
|
{
|
|
logger?.LogError("No archive file exists for game with ID {GameId} at the expected path {Path}", id, path);
|
|
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var fs = new FileStream(
|
|
path,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read,
|
|
1024 * 1024, // 1 MB buffer for higher throughput on large archive downloads
|
|
true);
|
|
|
|
var contentType = MediaTypeNames.Application.Octet;
|
|
var fileName = $"{game.Title.SanitizeFilename()}.zip";
|
|
|
|
return TypedResults.File(fs, contentType, fileName);
|
|
}
|
|
|
|
internal static async Task<IResult> ImportAsync(
|
|
[FromServices] ArchiveService archiveService,
|
|
[FromServices] ImportContext importContext,
|
|
[FromServices] ILogger<Game> logger,
|
|
Guid id)
|
|
{
|
|
try
|
|
{
|
|
var path = await archiveService.GetArchiveFileLocationAsync(id.ToString());
|
|
|
|
var result = await importContext.InitializeImportAsync(path);
|
|
|
|
return TypedResults.Ok(result);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Failed to import game with object key {ObjectKey}", id);
|
|
|
|
return TypedResults.BadRequest(ex.Message);
|
|
}
|
|
}
|
|
|
|
internal static async Task<IResult> UploadArchiveAsync(
|
|
[FromServices] ArchiveService archiveService,
|
|
[FromServices] StorageLocationService storageLocationService,
|
|
[FromServices] ILogger<Game> logger,
|
|
SDK.Models.UploadArchiveRequest request)
|
|
{
|
|
try
|
|
{
|
|
var storageLocation =
|
|
await storageLocationService.GetOrDefaultAsync(request.StorageLocationId, StorageLocationType.Archive);
|
|
|
|
var existingArchive = await archiveService.FirstOrDefaultAsync(a => a.GameId == request.Id && a.Version == request.Version);
|
|
var existingArchivePath = await archiveService.GetArchiveFileLocationAsync(existingArchive);
|
|
|
|
if (existingArchive == null)
|
|
{
|
|
existingArchive.ObjectKey = request.ObjectKey.ToString();
|
|
existingArchive.Changelog = request.Changelog;
|
|
existingArchive.StorageLocation = storageLocation;
|
|
|
|
var uploadedArchivePath = await archiveService.GetArchiveFileLocationAsync(existingArchive);
|
|
|
|
existingArchive.CompressedSize = new FileInfo(uploadedArchivePath).Length;
|
|
|
|
await archiveService.UpdateAsync(existingArchive);
|
|
|
|
File.Delete(existingArchivePath);
|
|
}
|
|
else
|
|
{
|
|
var archive = new Archive
|
|
{
|
|
ObjectKey = request.ObjectKey.ToString(),
|
|
Changelog = request.Changelog,
|
|
GameId = request.Id,
|
|
StorageLocation = storageLocation
|
|
};
|
|
|
|
var uploadedArchivePath = await archiveService.GetArchiveFileLocationAsync(archive);
|
|
|
|
archive.CompressedSize = new FileInfo(uploadedArchivePath).Length;
|
|
|
|
await archiveService.AddAsync(archive);
|
|
}
|
|
|
|
return TypedResults.Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex, "Could not upload game archive");
|
|
|
|
return TypedResults.BadRequest(ex.Message);
|
|
}
|
|
}
|
|
} |