|
| 1 | +using System.Text.Json; |
| 2 | +using Microsoft.ApplicationInsights; |
| 3 | +using Microsoft.AspNetCore.Authorization; |
| 4 | +using Microsoft.AspNetCore.Mvc; |
| 5 | +using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Configurations; |
| 6 | +using XtremeIdiots.Portal.Repository.Api.Client.V1; |
| 7 | +using XtremeIdiots.Portal.Web.Auth.Constants; |
| 8 | +using XtremeIdiots.Portal.Web.Extensions; |
| 9 | +using XtremeIdiots.Portal.Web.ViewModels; |
| 10 | + |
| 11 | +namespace XtremeIdiots.Portal.Web.Controllers; |
| 12 | + |
| 13 | +/// <summary> |
| 14 | +/// Manages fleet-wide global configuration defaults for agents, ban files, moderation, and events |
| 15 | +/// </summary> |
| 16 | +[Authorize(Policy = AuthPolicies.AccessGlobalSettings)] |
| 17 | +public class GlobalSettingsController( |
| 18 | + IRepositoryApiClient repositoryApiClient, |
| 19 | + TelemetryClient telemetryClient, |
| 20 | + ILogger<GlobalSettingsController> logger, |
| 21 | + IConfiguration configuration) : BaseController(telemetryClient, logger, configuration) |
| 22 | +{ |
| 23 | + private readonly static JsonSerializerOptions configJsonOptions = new() |
| 24 | + { |
| 25 | + PropertyNameCaseInsensitive = true |
| 26 | + }; |
| 27 | + |
| 28 | + [HttpGet] |
| 29 | + public async Task<IActionResult> Index(CancellationToken cancellationToken = default) |
| 30 | + { |
| 31 | + return await ExecuteWithErrorHandlingAsync(async () => |
| 32 | + { |
| 33 | + var model = new GlobalSettingsViewModel(); |
| 34 | + |
| 35 | + try |
| 36 | + { |
| 37 | + var configsResult = await repositoryApiClient.GlobalConfigurations.V1 |
| 38 | + .GetConfigurations(cancellationToken).ConfigureAwait(false); |
| 39 | + |
| 40 | + if (configsResult.IsSuccess && configsResult.Result?.Data?.Items != null) |
| 41 | + { |
| 42 | + foreach (var config in configsResult.Result.Data.Items) |
| 43 | + { |
| 44 | + PopulateModelFromNamespace(model, config); |
| 45 | + } |
| 46 | + } |
| 47 | + else |
| 48 | + { |
| 49 | + Logger.LogWarning("Failed to retrieve global configurations, using defaults"); |
| 50 | + } |
| 51 | + } |
| 52 | + catch (Exception ex) |
| 53 | + { |
| 54 | + Logger.LogWarning(ex, "Failed to fetch global configurations, using defaults"); |
| 55 | + } |
| 56 | + |
| 57 | + return View(model); |
| 58 | + }, nameof(Index)).ConfigureAwait(false); |
| 59 | + } |
| 60 | + |
| 61 | + [HttpPost] |
| 62 | + [ValidateAntiForgeryToken] |
| 63 | + public async Task<IActionResult> Index(GlobalSettingsViewModel model, CancellationToken cancellationToken = default) |
| 64 | + { |
| 65 | + return await ExecuteWithErrorHandlingAsync(async () => |
| 66 | + { |
| 67 | + var modelStateResult = CheckModelState(model); |
| 68 | + if (modelStateResult is not null) |
| 69 | + return modelStateResult; |
| 70 | + |
| 71 | + var errors = new List<string>(); |
| 72 | + |
| 73 | + await UpsertConfigSafeAsync("agent", JsonSerializer.Serialize(new |
| 74 | + { |
| 75 | + pollIntervalMs = model.AgentPollIntervalMs, |
| 76 | + statusPublishIntervalSeconds = model.AgentStatusPublishIntervalSeconds, |
| 77 | + rconSyncIntervalSeconds = model.AgentRconSyncIntervalSeconds, |
| 78 | + offsetSaveIntervalSeconds = model.AgentOffsetSaveIntervalSeconds |
| 79 | + }, configJsonOptions), errors, cancellationToken).ConfigureAwait(false); |
| 80 | + |
| 81 | + await UpsertConfigSafeAsync("banfiles", JsonSerializer.Serialize(new |
| 82 | + { |
| 83 | + checkIntervalSeconds = model.BanFileSyncCheckIntervalSeconds |
| 84 | + }, configJsonOptions), errors, cancellationToken).ConfigureAwait(false); |
| 85 | + |
| 86 | + await UpsertConfigSafeAsync("moderation", JsonSerializer.Serialize(new |
| 87 | + { |
| 88 | + contentSafetySeverityThreshold = model.ModerationSeverityThreshold, |
| 89 | + minMessageLength = model.ModerationMinMessageLength |
| 90 | + }, configJsonOptions), errors, cancellationToken).ConfigureAwait(false); |
| 91 | + |
| 92 | + await UpsertConfigSafeAsync("events", JsonSerializer.Serialize(new |
| 93 | + { |
| 94 | + staleThresholdSeconds = model.EventsStaleThresholdSeconds, |
| 95 | + playerCacheExpirationSeconds = model.EventsPlayerCacheExpirationSeconds |
| 96 | + }, configJsonOptions), errors, cancellationToken).ConfigureAwait(false); |
| 97 | + |
| 98 | + if (errors.Count > 0) |
| 99 | + { |
| 100 | + this.AddAlertDanger($"Failed to save configuration for: {string.Join(", ", errors)}"); |
| 101 | + } |
| 102 | + else |
| 103 | + { |
| 104 | + this.AddAlertSuccess("Global settings saved successfully."); |
| 105 | + } |
| 106 | + |
| 107 | + return RedirectToAction(nameof(Index)); |
| 108 | + }, nameof(Index)).ConfigureAwait(false); |
| 109 | + } |
| 110 | + |
| 111 | + private void PopulateModelFromNamespace(GlobalSettingsViewModel model, ConfigurationDto config) |
| 112 | + { |
| 113 | + try |
| 114 | + { |
| 115 | + if (string.IsNullOrWhiteSpace(config.Configuration)) |
| 116 | + return; |
| 117 | + |
| 118 | + using var doc = JsonDocument.Parse(config.Configuration); |
| 119 | + var root = doc.RootElement; |
| 120 | + |
| 121 | + switch (config.Namespace) |
| 122 | + { |
| 123 | + case "agent": |
| 124 | + model.AgentPollIntervalMs = GetIntProperty(root, "pollIntervalMs", model.AgentPollIntervalMs); |
| 125 | + model.AgentStatusPublishIntervalSeconds = GetIntProperty(root, "statusPublishIntervalSeconds", model.AgentStatusPublishIntervalSeconds); |
| 126 | + model.AgentRconSyncIntervalSeconds = GetIntProperty(root, "rconSyncIntervalSeconds", model.AgentRconSyncIntervalSeconds); |
| 127 | + model.AgentOffsetSaveIntervalSeconds = GetIntProperty(root, "offsetSaveIntervalSeconds", model.AgentOffsetSaveIntervalSeconds); |
| 128 | + break; |
| 129 | + case "banfiles": |
| 130 | + model.BanFileSyncCheckIntervalSeconds = GetIntProperty(root, "checkIntervalSeconds", model.BanFileSyncCheckIntervalSeconds); |
| 131 | + break; |
| 132 | + case "moderation": |
| 133 | + model.ModerationSeverityThreshold = GetIntProperty(root, "contentSafetySeverityThreshold", model.ModerationSeverityThreshold); |
| 134 | + model.ModerationMinMessageLength = GetIntProperty(root, "minMessageLength", model.ModerationMinMessageLength); |
| 135 | + break; |
| 136 | + case "events": |
| 137 | + model.EventsStaleThresholdSeconds = GetIntProperty(root, "staleThresholdSeconds", model.EventsStaleThresholdSeconds); |
| 138 | + model.EventsPlayerCacheExpirationSeconds = GetIntProperty(root, "playerCacheExpirationSeconds", model.EventsPlayerCacheExpirationSeconds); |
| 139 | + break; |
| 140 | + default: |
| 141 | + Logger.LogDebug("Unknown global configuration namespace '{Namespace}'", config.Namespace); |
| 142 | + break; |
| 143 | + } |
| 144 | + } |
| 145 | + catch (JsonException ex) |
| 146 | + { |
| 147 | + Logger.LogWarning(ex, "Failed to parse global configuration for namespace '{Namespace}'", config.Namespace); |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + private static int GetIntProperty(JsonElement root, string propertyName, int defaultValue) |
| 152 | + { |
| 153 | + return root.TryGetProperty(propertyName, out var prop) && |
| 154 | + prop.ValueKind == JsonValueKind.Number && |
| 155 | + prop.TryGetInt32(out var value) |
| 156 | + ? value |
| 157 | + : defaultValue; |
| 158 | + } |
| 159 | + |
| 160 | + private async Task UpsertConfigSafeAsync( |
| 161 | + string ns, |
| 162 | + string configJson, |
| 163 | + List<string> errors, |
| 164 | + CancellationToken cancellationToken) |
| 165 | + { |
| 166 | + try |
| 167 | + { |
| 168 | + var result = await repositoryApiClient.GlobalConfigurations.V1.UpsertConfiguration( |
| 169 | + ns, new UpsertConfigurationDto { Configuration = configJson }, cancellationToken).ConfigureAwait(false); |
| 170 | + |
| 171 | + if (!result.IsSuccess) |
| 172 | + { |
| 173 | + Logger.LogWarning("Failed to upsert global configuration namespace '{Namespace}'", ns); |
| 174 | + errors.Add(ns); |
| 175 | + } |
| 176 | + } |
| 177 | + catch (Exception ex) |
| 178 | + { |
| 179 | + Logger.LogWarning(ex, "Error upserting global configuration namespace '{Namespace}'", ns); |
| 180 | + errors.Add(ns); |
| 181 | + } |
| 182 | + } |
| 183 | +} |
0 commit comments