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. After a new instance is deployed, calls /// with the default Xibo admin credentials to obtain a session cookie. /// 2. Subsequent operations (create user, register OAuth2 app, set theme) authenticate /// using that session cookie — no pre-existing OAuth2 application is required. /// 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}" }; } } // ───────────────────────────────────────────────────────────────────────── // Session login // ───────────────────────────────────────────────────────────────────────── /// /// Obtains a Bearer access token using the OAuth2 client_credentials grant. /// The caller must have previously created an OAuth2 application in the Xibo CMS /// admin UI and provide the resulting and /// . /// public async Task LoginAsync(string instanceUrl, string clientId, string clientSecret) { var baseUrl = instanceUrl.TrimEnd('/'); var tokenUrl = $"{baseUrl}/api/authorize/access_token"; var client = _httpClientFactory.CreateClient("XiboApi"); client.Timeout = TimeSpan.FromSeconds(30); 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) { var body = await response.Content.ReadAsStringAsync(); throw new XiboAuthException( $"Xibo client_credentials login failed for client '{clientId}': HTTP {(int)response.StatusCode} — {body}", (int)response.StatusCode); } using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var accessToken = doc.RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("access_token missing in Xibo token response."); _logger.LogInformation("Xibo access token obtained for client '{ClientId}' at {Url}", clientId, baseUrl); return accessToken; } // ───────────────────────────────────────────────────────────────────────── // Health / readiness // ───────────────────────────────────────────────────────────────────────── /// /// Polls until Xibo is genuinely online by calling /// the public /about page (no auth required) and confirming the response body /// contains the word "Xibo". must already include the /// instance sub-path (e.g. https://ots.ots-signs.com/ots). /// The JSON /api/about and /api/clock endpoints both require auth, so /// the HTML about page is the only reliable unauthenticated Xibo-specific probe. /// A plain 200 from a proxy is not sufficient — the body must contain "Xibo". /// 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); var healthUrl = $"{baseUrl}/about"; _logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", healthUrl); while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested) { try { var response = await client.GetAsync(healthUrl, ct); if (response.IsSuccessStatusCode) { // The public /about page always contains the word "Xibo" in its HTML // when Xibo itself is serving responses. A proxy 200 page will not. var body = await response.Content.ReadAsStringAsync(ct); if (body.Contains("Xibo", StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Xibo is ready: {Url}", healthUrl); return true; } _logger.LogDebug("About page returned 200 but body lacks 'Xibo' — proxy may be up but Xibo not yet ready"); } } catch { /* not yet available */ } await Task.Delay(TimeSpan.FromSeconds(10), ct); } _logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, healthUrl); return false; } // ───────────────────────────────────────────────────────────────────────── // Admin user // ───────────────────────────────────────────────────────────────────────── /// /// Creates a new super-admin user in the Xibo instance and returns its numeric ID. /// Authenticates using the supplied (Bearer token /// obtained from ). /// public async Task CreateAdminUserAsync( string instanceUrl, string accessToken, string newUsername, string newPassword, string email, int groupId) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("userName", newUsername), new KeyValuePair("email", email), new KeyValuePair("userTypeId", "1"), // Super Admin new KeyValuePair("homePageId", "icondashboard.view"), new KeyValuePair("libraryQuota", "0"), new KeyValuePair("groupId", groupId.ToString()), new KeyValuePair("password", newPassword), new KeyValuePair("newUserWizard", "0"), new KeyValuePair("hideNavigation", "0"), 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. /// Authenticates using the supplied (Bearer token /// obtained from ). /// public async Task RotateUserPasswordAsync( string instanceUrl, string accessToken, int userId, string newPassword) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); 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. /// Authenticates using the supplied (Bearer token /// obtained from ). /// public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync( string instanceUrl, string accessToken, string appName) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); 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. /// Authenticates using the supplied (Bearer token /// obtained from ). /// public async Task SetThemeAsync( string instanceUrl, string accessToken, string themeFolderName = "otssigns") { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); // 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); } // ───────────────────────────────────────────────────────────────────────── // User groups // ───────────────────────────────────────────────────────────────────────── /// /// Creates a new user group and returns its numeric group ID. /// public async Task CreateUserGroupAsync( string instanceUrl, string accessToken, string groupName) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("group", groupName), new KeyValuePair("libraryQuota", "0"), }); var response = await client.PostAsync($"{baseUrl}/api/group", form); await EnsureSuccessAsync(response, "create Xibo user group"); // The response is an array containing the created group using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); var root = doc.RootElement; // Response may be an array or a single object depending on Xibo version var groupEl = root.ValueKind == JsonValueKind.Array ? root[0] : root; var gid = groupEl.GetProperty("groupId").GetInt32(); _logger.LogInformation("Xibo user group created: name={Name}, groupId={GroupId}", groupName, gid); return gid; } /// /// Assigns a user to a Xibo user group. /// public async Task AssignUserToGroupAsync( string instanceUrl, string accessToken, int groupId, int userId) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("userId[]", userId.ToString()), }); var response = await client.PostAsync($"{baseUrl}/api/group/members/assign/{groupId}", form); await EnsureSuccessAsync(response, $"assign user {userId} to group {groupId}"); _logger.LogInformation("User {UserId} assigned to group {GroupId}", userId, groupId); } // ───────────────────────────────────────────────────────────────────────── // User lookup / update / deletion // ───────────────────────────────────────────────────────────────────────── /// /// Updates an existing Xibo user's username, password, and email. /// Authenticates using the supplied . /// public async Task UpdateUserAsync( string instanceUrl, string accessToken, int userId, string newUsername, string newPassword, string email) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); var form = new FormUrlEncodedContent(new[] { new KeyValuePair("userName", newUsername), new KeyValuePair("email", email), new KeyValuePair("userTypeId", "1"), new KeyValuePair("homePageId", "icondashboard.view"), new KeyValuePair("libraryQuota", "0"), new KeyValuePair("newPassword", newPassword), new KeyValuePair("retypeNewPassword", newPassword), new KeyValuePair("newUserWizard", "0"), new KeyValuePair("hideNavigation", "0"), new KeyValuePair("isPasswordChangeRequired", "0"), }); var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form); await EnsureSuccessAsync(response, $"update Xibo user {userId}"); _logger.LogInformation("Xibo user updated: userId={UserId}, newUsername={Username}", userId, newUsername); } /// /// Finds a Xibo user by username and returns their numeric user ID. /// Authenticates using the supplied . /// public async Task GetUserIdByNameAsync(string instanceUrl, string accessToken, string username) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); var response = await client.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(username)}"); await EnsureSuccessAsync(response, "look up Xibo user by name"); using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); foreach (var user in doc.RootElement.EnumerateArray()) { var name = user.GetProperty("userName").GetString(); if (string.Equals(name, username, StringComparison.OrdinalIgnoreCase)) return user.GetProperty("userId").GetInt32(); } throw new InvalidOperationException( $"Xibo user '{username}' not found."); } /// /// Deletes a Xibo user by their numeric user ID. /// Authenticates using the supplied . /// public async Task DeleteUserAsync(string instanceUrl, string accessToken, int userId) { var client = _httpClientFactory.CreateClient("XiboApi"); var baseUrl = instanceUrl.TrimEnd('/'); SetBearer(client, accessToken); var response = await client.DeleteAsync($"{baseUrl}/api/user/{userId}"); await EnsureSuccessAsync(response, $"delete Xibo user {userId}"); _logger.LogInformation("Xibo user deleted: userId={UserId}", userId); } // ───────────────────────────────────────────────────────────────────────── // 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; }