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 GetAsync( [FromServices] UserService userService, [FromServices] GameService gameService, [FromServices] LibraryService libraryService, [FromServices] IOptions settings, [FromServices] IFusionCache cache, [FromServices] IMapper mapper, [FromServices] ILogger logger, ClaimsPrincipal userPrincipal) { var user = await userService.GetAsync(userPrincipal?.Identity?.Name ?? ""); var userLibrary = await libraryService.GetByUserIdAsync(user.Id); var mappedGames = await cache.GetOrSetAsync>("Games", async _ => { logger.LogDebug("Mapped games cache is empty, repopulating..."); var games = await gameService .AsNoTracking() .AsSplitQuery() .GetAsync(); return mapper.Map>(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 GetByIdAsync( [FromServices] GameService gameService, [FromServices] IFusionCache cache, [FromServices] IMapper mapper, Guid id) { var game = await cache.GetOrSetAsync($"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(result); }, TimeSpan.MaxValue, tags: ["Games", $"Games/{id}"]); if (game != null) return TypedResults.Ok(game); return TypedResults.NotFound(); } internal static async Task GetManifestByIdAsync( [FromServices] GameService gameService, [FromServices] IFusionCache cache, Guid id) { var manifest = await cache .GetOrSetAsync( $"Game/{id}/Manifest", async _ => await gameService.GetManifestAsync(id), TimeSpan.MaxValue, tags: ["Games", $"Games/{id}"]); return TypedResults.Ok(manifest); } internal static async Task GetActionsByIdAsync( [FromServices] UserService userService, [FromServices] GameService gameService, [FromServices] LibraryService libraryService, [FromServices] SettingsProvider settingsProvider, [FromServices] IFusionCache cache, [FromServices] IMapper mapper, [FromServices] ILogger logger, ClaimsPrincipal userPrincipal, Guid id) { var actions = await cache.GetOrSetAsync>($"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(); 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>(dataActions); foreach (var server in game.Servers) { foreach (var serverAction in server.Actions) { var mappedAction = mapper.Map(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 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>(results); }, tags: ["Games", $"Games/{id}"]); return TypedResults.Ok(addons); } internal static async Task 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>(results); }, tags: ["Tools", "Games", $"Games/{id}"]); return TypedResults.Ok(tools); } internal static async Task 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>(results); }, tags: ["Scripts", $"Games/{id}/Scripts", "Games", $"Games/{id}"]); return TypedResults.Ok(scripts); } internal static async Task 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(); var logger = scope.ServiceProvider.GetRequiredService>(); 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 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 GetUpdatesAsync( [FromServices] GameService gameService, [FromServices] IMapper mapper, [FromServices] ILogger logger, Guid id, string version) { try { var archives = await gameService.GetUpdatesAsync(id, version); var mapped = mapper.Map>(archives); return TypedResults.Ok(mapped); } catch (Exception ex) { logger?.LogError(ex, "Could not get updates for game {GameId}", id); return TypedResults.Ok(Enumerable.Empty()); } } internal static async Task CheckForUpdateAsync( [FromServices] GameService gameService, [FromServices] ILogger 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 DownloadAsync( [FromServices] GameService gameService, [FromServices] ArchiveService archiveService, [FromServices] DownloadThrottle downloadThrottle, [FromServices] IOptions settings, [FromServices] ILogger 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"; var stream = await downloadThrottle.ApplyAsync(fs, userPrincipal); return TypedResults.File(stream, contentType, fileName); } internal static async Task ImportAsync( [FromServices] ArchiveService archiveService, [FromServices] ImportContext importContext, [FromServices] ILogger 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 UploadArchiveAsync( [FromServices] ArchiveService archiveService, [FromServices] StorageLocationService storageLocationService, [FromServices] ILogger 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); } } }