Skip to content

Commit d3206af

Browse files
feat: Implement screenshot management for game servers with authorization and UI integration
1 parent c19ecf5 commit d3206af

11 files changed

Lines changed: 594 additions & 7 deletions

File tree

src/XtremeIdiots.Portal.Web.Tests/Auth/Handlers/GameServersAuthHandlerTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,45 @@ public async Task HandleAsync_FileTransportRead_SucceedsForFileTransportClaim()
3737
Assert.True(context.HasSucceeded);
3838
}
3939

40+
[Fact]
41+
public async Task HandleAsync_ScreenshotsRead_SucceedsForMatchingGameScopedClaim()
42+
{
43+
var requirement = new GameServersAdminScreenshotsRead();
44+
var user = CreateUser(new Claim(AuthPolicies.GameServers_Admin_Screenshots_Read, GameType.CallOfDuty4.ToString()));
45+
var context = new AuthorizationHandlerContext([requirement], user, GameType.CallOfDuty4);
46+
47+
var sut = new GameServersAuthHandler();
48+
await sut.HandleAsync(context);
49+
50+
Assert.True(context.HasSucceeded);
51+
}
52+
53+
[Fact]
54+
public async Task HandleAsync_ScreenshotsRead_DoesNotSucceedForDifferentGameScopedClaim()
55+
{
56+
var requirement = new GameServersAdminScreenshotsRead();
57+
var user = CreateUser(new Claim(AuthPolicies.GameServers_Admin_Screenshots_Read, GameType.Insurgency.ToString()));
58+
var context = new AuthorizationHandlerContext([requirement], user, GameType.CallOfDuty4);
59+
60+
var sut = new GameServersAuthHandler();
61+
await sut.HandleAsync(context);
62+
63+
Assert.False(context.HasSucceeded);
64+
}
65+
66+
[Fact]
67+
public async Task HandleAsync_RconScreenshot_SucceedsForMatchingGameScopedClaim()
68+
{
69+
var requirement = new GameServersAdminRconScreenshot();
70+
var user = CreateUser(new Claim(AuthPolicies.GameServers_Admin_Rcon_Screenshot, GameType.CallOfDuty4.ToString()));
71+
var context = new AuthorizationHandlerContext([requirement], user, GameType.CallOfDuty4);
72+
73+
var sut = new GameServersAuthHandler();
74+
await sut.HandleAsync(context);
75+
76+
Assert.True(context.HasSucceeded);
77+
}
78+
4079
private static ClaimsPrincipal CreateUser(params Claim[] claims)
4180
{
4281
var identity = new ClaimsIdentity(claims, authenticationType: "TestAuthType");

src/XtremeIdiots.Portal.Web/Auth/Constants/AuthPolicies.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ public static class AuthPolicies
4444
public const string GameServers_Admin_Rcon_Map = "GameServers.Admin.Rcon.Map";
4545
public const string GameServers_Admin_Rcon_Say = "GameServers.Admin.Rcon.Say";
4646
public const string GameServers_Admin_Rcon_Restart = "GameServers.Admin.Rcon.Restart";
47+
public const string GameServers_Admin_Rcon_Screenshot = "GameServers.Admin.Rcon.Screenshot";
48+
public const string GameServers_Admin_Screenshots_Read = "GameServers.Admin.Screenshots.Read";
49+
public const string GameServers_Admin_Screenshots_Delete = "GameServers.Admin.Screenshots.Delete";
4750

4851
// Chat Log
4952
public const string ChatLog_Read = "ChatLog.Read";

src/XtremeIdiots.Portal.Web/Auth/Handlers/GameServersAuthHandler.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ public Task HandleAsync(AuthorizationHandlerContext context)
8080
case GameServersAdminRconRestart:
8181
HandleAdminRconRestart(context, requirement);
8282
break;
83+
case GameServersAdminRconScreenshot:
84+
HandleAdminRconScreenshot(context, requirement);
85+
break;
86+
case GameServersAdminScreenshotsRead:
87+
HandleAdminScreenshotsRead(context, requirement);
88+
break;
89+
case GameServersAdminScreenshotsDelete:
90+
HandleAdminScreenshotsDelete(context, requirement);
91+
break;
8392
default:
8493
break;
8594
}
@@ -264,5 +273,23 @@ private static void HandleAdminRconRestart(AuthorizationHandlerContext context,
264273
BaseAuthorizationHelper.CheckDirectPermissionGrant(context, requirement, "GameServers.Admin.Rcon.Restart");
265274
}
266275

276+
private static void HandleAdminRconScreenshot(AuthorizationHandlerContext context, IAuthorizationRequirement requirement)
277+
{
278+
BaseAuthorizationHelper.CheckSeniorOrGameAdminAccessWithResource(context, requirement);
279+
BaseAuthorizationHelper.CheckDirectPermissionGrant(context, requirement, "GameServers.Admin.Rcon.Screenshot");
280+
}
281+
282+
private static void HandleAdminScreenshotsRead(AuthorizationHandlerContext context, IAuthorizationRequirement requirement)
283+
{
284+
BaseAuthorizationHelper.CheckSeniorOrGameAdminAccessWithResource(context, requirement);
285+
BaseAuthorizationHelper.CheckDirectPermissionGrant(context, requirement, "GameServers.Admin.Screenshots.Read");
286+
}
287+
288+
private static void HandleAdminScreenshotsDelete(AuthorizationHandlerContext context, IAuthorizationRequirement requirement)
289+
{
290+
BaseAuthorizationHelper.CheckSeniorOrGameAdminAccessWithResource(context, requirement);
291+
BaseAuthorizationHelper.CheckDirectPermissionGrant(context, requirement, "GameServers.Admin.Screenshots.Delete");
292+
}
293+
267294
#endregion
268295
}

src/XtremeIdiots.Portal.Web/Auth/Requirements/AuthRequirements.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ public class GameServersAdminRconBan : IAuthorizationRequirement { }
3737
public class GameServersAdminRconMap : IAuthorizationRequirement { }
3838
public class GameServersAdminRconSay : IAuthorizationRequirement { }
3939
public class GameServersAdminRconRestart : IAuthorizationRequirement { }
40+
public class GameServersAdminRconScreenshot : IAuthorizationRequirement { }
41+
public class GameServersAdminScreenshotsRead : IAuthorizationRequirement { }
42+
public class GameServersAdminScreenshotsDelete : IAuthorizationRequirement { }
4043

4144
// Chat Log
4245
public class ChatLogRead : IAuthorizationRequirement { }

src/XtremeIdiots.Portal.Web/Controllers/ServerAdminController.cs

Lines changed: 199 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.LiveStatus;
1717
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Maps;
1818
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Players;
19+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Screenshots;
1920
using XtremeIdiots.Portal.Repository.Api.Client.V1;
2021
using XtremeIdiots.Portal.Web.Auth.Constants;
2122
using XtremeIdiots.Portal.Web.Extensions;
@@ -155,13 +156,16 @@ public async Task<IActionResult> ServerDetail(Guid id, CancellationToken cancell
155156
var mapRotAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.MapRotations_Read);
156157
var statusAuth = authorizationService.AuthorizeAsync(User, AuthPolicies.GameServers_BanFileMonitors_Read);
157158
var editAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Write);
159+
var screenshotsReadAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Admin_Screenshots_Read);
160+
var screenshotsDeleteAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Admin_Screenshots_Delete);
158161

159162
// Check fine-grained RCON sub-action permissions in parallel
160163
var sayAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Admin_Rcon_Say);
161164
var mapCmdAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Admin_Rcon_Map);
162165
var restartSrvAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Admin_Rcon_Restart);
166+
var screenshotCmdAuth = authorizationService.AuthorizeAsync(User, gs.GameType, AuthPolicies.GameServers_Admin_Rcon_Screenshot);
163167

164-
await Task.WhenAll(rconAuth, chatAuth, mapRotAuth, statusAuth, editAuth, sayAuth, mapCmdAuth, restartSrvAuth).ConfigureAwait(false);
168+
await Task.WhenAll(rconAuth, chatAuth, mapRotAuth, statusAuth, editAuth, screenshotsReadAuth, screenshotsDeleteAuth, sayAuth, mapCmdAuth, restartSrvAuth, screenshotCmdAuth).ConfigureAwait(false);
165169

166170
var viewModel = new ServerDetailViewModel
167171
{
@@ -172,9 +176,12 @@ public async Task<IActionResult> ServerDetail(Guid id, CancellationToken cancell
172176
CanViewMapRotation = (await mapRotAuth.ConfigureAwait(false)).Succeeded,
173177
CanViewStatus = (await statusAuth.ConfigureAwait(false)).Succeeded,
174178
CanEditServer = (await editAuth.ConfigureAwait(false)).Succeeded,
179+
CanViewScreenshots = (await screenshotsReadAuth.ConfigureAwait(false)).Succeeded,
175180
CanSay = (await sayAuth.ConfigureAwait(false)).Succeeded,
176181
CanChangeMap = (await mapCmdAuth.ConfigureAwait(false)).Succeeded,
177-
CanRestartServer = (await restartSrvAuth.ConfigureAwait(false)).Succeeded
182+
CanRestartServer = (await restartSrvAuth.ConfigureAwait(false)).Succeeded,
183+
CanTakeScreenshot = (await screenshotCmdAuth.ConfigureAwait(false)).Succeeded,
184+
CanDeleteScreenshots = (await screenshotsDeleteAuth.ConfigureAwait(false)).Succeeded
178185
};
179186

180187
// Fetch overview data (non-critical — page renders without it)
@@ -1151,6 +1158,196 @@ await CreateAdminActionForRconOperationAsync(
11511158
}, nameof(BanRconPlayer)).ConfigureAwait(false);
11521159
}
11531160

1161+
[HttpPost]
1162+
[ValidateAntiForgeryToken]
1163+
public async Task<IActionResult> TakeRconScreenshot(Guid id, string playerIdentifier, string playerName, CancellationToken cancellationToken = default)
1164+
{
1165+
return await ExecuteWithErrorHandlingAsync(async () =>
1166+
{
1167+
var (actionResult, gameServerData) = await GetAuthorizedGameServerAsync(id, nameof(TakeRconScreenshot), cancellationToken).ConfigureAwait(false);
1168+
if (actionResult is not null)
1169+
return actionResult;
1170+
1171+
var screenshotAuthResult = await CheckAuthorizationAsync(
1172+
authorizationService,
1173+
gameServerData!.GameType,
1174+
AuthPolicies.GameServers_Admin_Rcon_Screenshot,
1175+
nameof(TakeRconScreenshot),
1176+
"GameServer",
1177+
$"ServerId:{id},GameType:{gameServerData.GameType}",
1178+
gameServerData).ConfigureAwait(false);
1179+
1180+
if (screenshotAuthResult is not null)
1181+
return Json(new { success = false, message = "You don't have permission to request screenshots" });
1182+
1183+
if (string.IsNullOrWhiteSpace(playerIdentifier))
1184+
{
1185+
return Json(new { success = false, message = "Player identifier is required" });
1186+
}
1187+
1188+
var result = await serversApiClient.Rcon.V1.TakeScreenshot(id, new TakeScreenshotRequestDto
1189+
{
1190+
PlayerIdentifier = playerIdentifier.Trim()
1191+
}, cancellationToken).ConfigureAwait(false);
1192+
1193+
if (!result.IsSuccess)
1194+
{
1195+
return Json(new { success = false, message = "Failed to request screenshot from game server" });
1196+
}
1197+
1198+
TrackSuccessTelemetry("RconScreenshotRequested", nameof(TakeRconScreenshot), new Dictionary<string, string>
1199+
{
1200+
{ "ServerId", id.ToString() },
1201+
{ "GameType", gameServerData.GameType.ToString() },
1202+
{ "PlayerIdentifier", playerIdentifier.Trim() }
1203+
});
1204+
1205+
return Json(new { success = true, message = $"Screenshot requested for {(string.IsNullOrWhiteSpace(playerName) ? "player" : playerName)}. It may take a short time to appear." });
1206+
}, nameof(TakeRconScreenshot)).ConfigureAwait(false);
1207+
}
1208+
1209+
[HttpGet]
1210+
public async Task<IActionResult> GetScreenshots(Guid id, int skipEntries = 0, int takeEntries = 1000, CancellationToken cancellationToken = default)
1211+
{
1212+
return await ExecuteWithErrorHandlingAsync(async () =>
1213+
{
1214+
var gameServerResponse = await repositoryApiClient.GameServers.V1.GetGameServer(id, cancellationToken).ConfigureAwait(false);
1215+
if (gameServerResponse.IsNotFound || gameServerResponse.Result?.Data is null)
1216+
{
1217+
return NotFound();
1218+
}
1219+
1220+
var gameServer = gameServerResponse.Result.Data;
1221+
var authResult = await CheckAuthorizationAsync(
1222+
authorizationService,
1223+
gameServer.GameType,
1224+
AuthPolicies.GameServers_Admin_Screenshots_Read,
1225+
nameof(GetScreenshots),
1226+
"GameServer",
1227+
$"ServerId:{id},GameType:{gameServer.GameType}",
1228+
gameServer).ConfigureAwait(false);
1229+
1230+
if (authResult is not null)
1231+
return authResult;
1232+
1233+
var screenshotsResponse = await repositoryApiClient.Screenshots.V1.GetScreenshots(
1234+
id,
1235+
Math.Max(skipEntries, 0),
1236+
Math.Clamp(takeEntries, 1, 2000),
1237+
ScreenshotOrder.CapturedUtcDesc,
1238+
cancellationToken).ConfigureAwait(false);
1239+
1240+
if (!screenshotsResponse.IsSuccess || screenshotsResponse.Result?.Data?.Items is null)
1241+
{
1242+
return Json(new { data = Array.Empty<object>() });
1243+
}
1244+
1245+
var data = screenshotsResponse.Result.Data.Items.Select(s => new
1246+
{
1247+
screenshotId = s.ScreenshotId,
1248+
capturedUtc = s.CapturedUtc,
1249+
playerIdentifier = s.PlayerIdentifier,
1250+
playerName = s.PlayerName,
1251+
sourceFileName = s.SourceFileName,
1252+
sizeBytes = s.SizeBytes,
1253+
contentType = s.ContentType
1254+
});
1255+
1256+
return Json(new { data });
1257+
}, nameof(GetScreenshots)).ConfigureAwait(false);
1258+
}
1259+
1260+
[HttpGet]
1261+
public async Task<IActionResult> GetScreenshotContent(Guid id, Guid screenshotId, CancellationToken cancellationToken = default)
1262+
{
1263+
return await ExecuteWithErrorHandlingAsync(async () =>
1264+
{
1265+
var screenshotResponse = await repositoryApiClient.Screenshots.V1.GetScreenshot(screenshotId, cancellationToken).ConfigureAwait(false);
1266+
if (screenshotResponse.IsNotFound || screenshotResponse.Result?.Data is null)
1267+
{
1268+
return NotFound();
1269+
}
1270+
1271+
var screenshot = screenshotResponse.Result.Data;
1272+
if (screenshot.GameServerId != id)
1273+
{
1274+
return NotFound();
1275+
}
1276+
1277+
if (!Enum.TryParse<GameType>(screenshot.GameType, true, out var gameType) || gameType == GameType.Unknown)
1278+
{
1279+
return BadRequest();
1280+
}
1281+
1282+
var authResult = await CheckAuthorizationAsync(
1283+
authorizationService,
1284+
gameType,
1285+
AuthPolicies.GameServers_Admin_Screenshots_Read,
1286+
nameof(GetScreenshotContent),
1287+
"Screenshot",
1288+
$"ScreenshotId:{screenshotId},ServerId:{id},GameType:{gameType}",
1289+
screenshot).ConfigureAwait(false);
1290+
1291+
if (authResult is not null)
1292+
return authResult;
1293+
1294+
var contentResponse = await repositoryApiClient.Screenshots.V1.GetScreenshotContent(screenshotId, cancellationToken).ConfigureAwait(false);
1295+
if (!contentResponse.IsSuccess || contentResponse.Result?.Data is null)
1296+
{
1297+
return NotFound();
1298+
}
1299+
1300+
var content = contentResponse.Result.Data;
1301+
var fileName = string.IsNullOrWhiteSpace(content.FileName) ? $"{screenshotId}.jpg" : content.FileName;
1302+
return File(content.Content, content.ContentType, fileName);
1303+
}, nameof(GetScreenshotContent)).ConfigureAwait(false);
1304+
}
1305+
1306+
[HttpPost]
1307+
[ValidateAntiForgeryToken]
1308+
public async Task<IActionResult> DeleteScreenshot(Guid id, Guid screenshotId, CancellationToken cancellationToken = default)
1309+
{
1310+
return await ExecuteWithErrorHandlingAsync(async () =>
1311+
{
1312+
var screenshotResponse = await repositoryApiClient.Screenshots.V1.GetScreenshot(screenshotId, cancellationToken).ConfigureAwait(false);
1313+
if (screenshotResponse.IsNotFound || screenshotResponse.Result?.Data is null)
1314+
{
1315+
return Json(new { success = false, message = "Screenshot not found" });
1316+
}
1317+
1318+
var screenshot = screenshotResponse.Result.Data;
1319+
if (screenshot.GameServerId != id)
1320+
{
1321+
return Json(new { success = false, message = "Screenshot not found" });
1322+
}
1323+
1324+
if (!Enum.TryParse<GameType>(screenshot.GameType, true, out var gameType) || gameType == GameType.Unknown)
1325+
{
1326+
return Json(new { success = false, message = "Invalid screenshot game type" });
1327+
}
1328+
1329+
var authResult = await CheckAuthorizationAsync(
1330+
authorizationService,
1331+
gameType,
1332+
AuthPolicies.GameServers_Admin_Screenshots_Delete,
1333+
nameof(DeleteScreenshot),
1334+
"Screenshot",
1335+
$"ScreenshotId:{screenshotId},ServerId:{id},GameType:{gameType}",
1336+
screenshot).ConfigureAwait(false);
1337+
1338+
if (authResult is not null)
1339+
return Json(new { success = false, message = "You don't have permission to delete screenshots" });
1340+
1341+
var deleteResponse = await repositoryApiClient.Screenshots.V1.DeleteScreenshot(screenshotId, cancellationToken).ConfigureAwait(false);
1342+
if (!deleteResponse.IsSuccess)
1343+
{
1344+
return Json(new { success = false, message = "Failed to delete screenshot" });
1345+
}
1346+
1347+
return Json(new { success = true, message = "Screenshot deleted" });
1348+
}, nameof(DeleteScreenshot)).ConfigureAwait(false);
1349+
}
1350+
11541351
/// <summary>
11551352
/// Creates an admin action record for an RCON operation (kick/ban)
11561353
/// </summary>

src/XtremeIdiots.Portal.Web/Extensions/PolicyExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ public static void AddXtremeIdiotsPolicies(this AuthorizationOptions options)
4444
options.AddPolicy(AuthPolicies.GameServers_Admin_Rcon_Map, policy => policy.Requirements.Add(new GameServersAdminRconMap()));
4545
options.AddPolicy(AuthPolicies.GameServers_Admin_Rcon_Say, policy => policy.Requirements.Add(new GameServersAdminRconSay()));
4646
options.AddPolicy(AuthPolicies.GameServers_Admin_Rcon_Restart, policy => policy.Requirements.Add(new GameServersAdminRconRestart()));
47+
options.AddPolicy(AuthPolicies.GameServers_Admin_Rcon_Screenshot, policy => policy.Requirements.Add(new GameServersAdminRconScreenshot()));
48+
options.AddPolicy(AuthPolicies.GameServers_Admin_Screenshots_Read, policy => policy.Requirements.Add(new GameServersAdminScreenshotsRead()));
49+
options.AddPolicy(AuthPolicies.GameServers_Admin_Screenshots_Delete, policy => policy.Requirements.Add(new GameServersAdminScreenshotsDelete()));
4750

4851
// Chat Log
4952
options.AddPolicy(AuthPolicies.ChatLog_Read, policy => policy.Requirements.Add(new ChatLogRead()));

src/XtremeIdiots.Portal.Web/ViewModels/ServerDetailViewModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ public class ServerDetailViewModel
2020
public bool CanViewMapRotation { get; set; }
2121
public bool CanViewStatus { get; set; }
2222
public bool CanEditServer { get; set; }
23+
public bool CanViewScreenshots { get; set; }
2324

2425
// Fine-grained RCON action flags — determines which buttons are rendered within the RCON tab
2526
public bool CanSay { get; set; }
2627
public bool CanChangeMap { get; set; }
2728
public bool CanRestartServer { get; set; }
29+
public bool CanTakeScreenshot { get; set; }
30+
public bool CanDeleteScreenshots { get; set; }
2831

2932
// Overview tab data
3033
public AgentServerStatus? AgentStatus { get; set; }

0 commit comments

Comments
 (0)