using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OTSSignsOrchestrator.Core.Configuration; namespace OTSSignsOrchestrator.Core.Services; /// /// Provides connectivity testing and administrative operations against deployed Xibo CMS instances. /// /// Bootstrap flow: /// 1. A Xibo OAuth2 application with client_credentials grant must be created once /// (stored in Settings → Xibo.BootstrapClientId / Xibo.BootstrapClientSecret). /// 2. After a new instance is deployed, PostInstanceInitService calls into this service /// to create the OTS admin user, register a dedicated OAuth2 app, and set the theme. /// public class XiboApiService { private readonly IHttpClientFactory _httpClientFactory; private readonly XiboOptions _options; private readonly ILogger _logger; public XiboApiService( IHttpClientFactory httpClientFactory, IOptions options, ILogger logger) { _httpClientFactory = httpClientFactory; _options = options.Value; _logger = logger; } // ───────────────────────────────────────────────────────────────────────── // Connection test // ───────────────────────────────────────────────────────────────────────── public async Task TestConnectionAsync(string instanceUrl, string clientId, string clientSecret) { _logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl); var client = _httpClientFactory.CreateClient("XiboApi"); client.Timeout = TimeSpan.FromSeconds(_options.TestConnectionTimeoutSeconds); try { var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client); _logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl); return new XiboTestResult { IsValid = true, Message = "Connected successfully.", HttpStatus = 200 }; } catch (XiboAuthException ex) { return new XiboTestResult { IsValid = false, Message = ex.Message, HttpStatus = ex.HttpStatus }; } catch (TaskCanceledException) { return new XiboTestResult { IsValid = false, Message = "Connection timed out." }; } catch (HttpRequestException ex) { return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" }; } } // ───────────────────────────────────────────────────────────────────────── // Health / readiness // ───────────────────────────────────────────────────────────────────────── /// /// Polls until Xibo returns a 200 from its /// /about endpoint or elapses. /// public async Task WaitForReadyAsync( string instanceUrl, TimeSpan timeout, CancellationToken ct = default) { var deadline = DateTime.UtcNow + timeout; var baseUrl = instanceUrl.TrimEnd('/'); var client = _httpClientFactory.CreateClient("XiboHealth"); client.Timeout = TimeSpan.FromSeconds(10); _logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", baseUrl); while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) { try { var response = await client.GetAsync($"{baseUrl}/api/about", ct); if (response.IsSuccessStatusCode) { _logger.LogInformation("Xibo is ready: {Url}", baseUrl); return true; } } catch { /* not yet available */ } await Task.Delay(TimeSpan.FromSeconds(10), ct); } _logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl); return false; } // ───────────────────────────────────────────────────────────────────────── // Admin user // ───────────────────────────────────────────────────────────────────────── /// /// Creates a new super-admin user in the Xibo instance and returns its numeric ID. /// public async Task CreateAdminUserAsync( string instanceUrl, string bootstrapClientId, string bootstrapClientSecret, string newUsername, string newPassword, string email) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); SetBearer(client, token); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("userName", newUsername), new KeyValuePair("email", email), new KeyValuePair("userTypeId", "1"), // Super Admin new KeyValuePair("homePageId", "1"), new KeyValuePair("libraryQuota", "0"), new KeyValuePair("groupId", "1"), new KeyValuePair("newUserPassword", newPassword), new KeyValuePair("retypeNewUserPassword", newPassword), new KeyValuePair("isPasswordChangeRequired", "0"), }); var response = await client.PostAsync($"{baseUrl}/api/user", form); await EnsureSuccessAsync(response, "create Xibo admin user"); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var userId = doc.RootElement.GetProperty("userId").GetInt32(); _logger.LogInformation("Xibo admin user created: username={Username}, userId={UserId}", newUsername, userId); return userId; } /// /// Changes the password of an existing Xibo user. /// public async Task RotateUserPasswordAsync( string instanceUrl, string bootstrapClientId, string bootstrapClientSecret, int userId, string newPassword) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); SetBearer(client, token); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("newUserPassword", newPassword), new KeyValuePair("retypeNewUserPassword", newPassword), }); var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form); await EnsureSuccessAsync(response, "rotate Xibo user password"); _logger.LogInformation("Xibo user password rotated: userId={UserId}", userId); } // ───────────────────────────────────────────────────────────────────────── // OAuth2 application // ───────────────────────────────────────────────────────────────────────── /// /// Registers a new client_credentials OAuth2 application in Xibo and returns /// the generated client_id and client_secret. /// public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync( string instanceUrl, string bootstrapClientId, string bootstrapClientSecret, string appName) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); SetBearer(client, token); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("name", appName), new KeyValuePair("clientId", Guid.NewGuid().ToString("N")), new KeyValuePair("confidential", "1"), new KeyValuePair("authCode", "0"), new KeyValuePair("clientCredentials", "1"), }); var response = await client.PostAsync($"{baseUrl}/api/application", form); await EnsureSuccessAsync(response, "register Xibo OAuth2 application"); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var root = doc.RootElement; var cid = root.GetProperty("key").GetString() ?? throw new InvalidOperationException("Xibo application 'key' missing in response."); var secret = root.GetProperty("secret").GetString() ?? throw new InvalidOperationException("Xibo application 'secret' missing in response."); _logger.LogInformation("Xibo OAuth2 application registered: name={Name}, clientId={ClientId}", appName, cid); return (cid, secret); } // ───────────────────────────────────────────────────────────────────────── // Theme // ───────────────────────────────────────────────────────────────────────── /// /// Sets the active CMS theme by writing the THEME_FOLDER setting. /// public async Task SetThemeAsync( string instanceUrl, string bootstrapClientId, string bootstrapClientSecret, string themeFolderName = "otssigns") { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client); SetBearer(client, token); // Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns var form = new FormUrlEncodedContent(new[] { new KeyValuePair("settings[THEME_FOLDER]", themeFolderName), }); var response = await client.PostAsync($"{baseUrl}/api/admin/setting", form); await EnsureSuccessAsync(response, "set Xibo theme"); _logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private async Task GetTokenAsync( string baseUrl, string clientId, string clientSecret, HttpClient client) { var tokenUrl = $"{baseUrl}/api/authorize/access_token"; var form = new FormUrlEncodedContent(new[] { new KeyValuePair("grant_type", "client_credentials"), new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), }); var response = await client.PostAsync(tokenUrl, form); if (!response.IsSuccessStatusCode) { throw new XiboAuthException( response.StatusCode switch { System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.", System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.", System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.", _ => $"Unexpected response: {(int)response.StatusCode}" }, (int)response.StatusCode); } using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var aToken = doc.RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("access_token missing in Xibo token response."); return aToken; } private static void SetBearer(HttpClient client, string token) => client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation) { if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(); throw new InvalidOperationException( $"Xibo API call '{operation}' failed: {(int)response.StatusCode} — {body}"); } } } // ───────────────────────────────────────────────────────────────────────────── // Result / exception types // ───────────────────────────────────────────────────────────────────────────── public class XiboTestResult { public bool IsValid { get; set; } public string Message { get; set; } = string.Empty; public int HttpStatus { get; set; } } public class XiboAuthException : Exception { public int HttpStatus { get; } public XiboAuthException(string message, int httpStatus) : base(message) => HttpStatus = httpStatus; }