2026-02-25 08:05:44 -05:00
|
|
|
using System.Net.Http.Headers;
|
|
|
|
|
using System.Net.Http.Json;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using System.Text.Json.Serialization;
|
2026-02-18 10:43:27 -05:00
|
|
|
using Microsoft.Extensions.Logging;
|
2026-02-12 15:24:25 -05:00
|
|
|
using Microsoft.Extensions.Options;
|
2026-02-18 10:43:27 -05:00
|
|
|
using OTSSignsOrchestrator.Core.Configuration;
|
2026-02-12 15:24:25 -05:00
|
|
|
|
2026-02-18 10:43:27 -05:00
|
|
|
namespace OTSSignsOrchestrator.Core.Services;
|
2026-02-12 15:24:25 -05:00
|
|
|
|
|
|
|
|
/// <summary>
|
2026-02-25 08:05:44 -05:00
|
|
|
/// 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.
|
2026-02-12 15:24:25 -05:00
|
|
|
/// </summary>
|
|
|
|
|
public class XiboApiService
|
|
|
|
|
{
|
|
|
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
|
private readonly XiboOptions _options;
|
|
|
|
|
private readonly ILogger<XiboApiService> _logger;
|
|
|
|
|
|
|
|
|
|
public XiboApiService(
|
|
|
|
|
IHttpClientFactory httpClientFactory,
|
|
|
|
|
IOptions<XiboOptions> options,
|
|
|
|
|
ILogger<XiboApiService> logger)
|
|
|
|
|
{
|
|
|
|
|
_httpClientFactory = httpClientFactory;
|
|
|
|
|
_options = options.Value;
|
|
|
|
|
_logger = logger;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 08:05:44 -05:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Connection test
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string clientId, string clientSecret)
|
2026-02-12 15:24:25 -05:00
|
|
|
{
|
|
|
|
|
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
|
|
|
|
|
|
|
|
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
|
|
|
|
client.Timeout = TimeSpan.FromSeconds(_options.TestConnectionTimeoutSeconds);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client);
|
|
|
|
|
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
|
|
|
|
return new XiboTestResult
|
2026-02-12 15:24:25 -05:00
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
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
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Polls <paramref name="instanceUrl"/> until Xibo returns a 200 from its
|
|
|
|
|
/// <c>/about</c> endpoint or <paramref name="timeout"/> elapses.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<bool> 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);
|
2026-02-12 15:24:25 -05:00
|
|
|
|
2026-02-25 08:05:44 -05:00
|
|
|
_logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", baseUrl);
|
2026-02-12 15:24:25 -05:00
|
|
|
|
2026-02-25 08:05:44 -05:00
|
|
|
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
|
|
|
|
|
{
|
|
|
|
|
try
|
2026-02-12 15:24:25 -05:00
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
var response = await client.GetAsync($"{baseUrl}/api/about", ct);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
2026-02-12 15:24:25 -05:00
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
_logger.LogInformation("Xibo is ready: {Url}", baseUrl);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-02-12 15:24:25 -05:00
|
|
|
}
|
2026-02-25 08:05:44 -05:00
|
|
|
catch { /* not yet available */ }
|
2026-02-12 15:24:25 -05:00
|
|
|
|
2026-02-25 08:05:44 -05:00
|
|
|
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
|
|
|
|
}
|
2026-02-12 15:24:25 -05:00
|
|
|
|
2026-02-25 08:05:44 -05:00
|
|
|
_logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Admin user
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<int> 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<string, string>("userName", newUsername),
|
|
|
|
|
new KeyValuePair<string, string>("email", email),
|
|
|
|
|
new KeyValuePair<string, string>("userTypeId", "1"), // Super Admin
|
|
|
|
|
new KeyValuePair<string, string>("homePageId", "1"),
|
|
|
|
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
|
|
|
|
new KeyValuePair<string, string>("groupId", "1"),
|
|
|
|
|
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
|
|
|
|
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
|
|
|
|
new KeyValuePair<string, string>("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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Changes the password of an existing Xibo user.
|
|
|
|
|
/// </summary>
|
|
|
|
|
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<string, string>("newUserPassword", newPassword),
|
|
|
|
|
new KeyValuePair<string, string>("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
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Registers a new client_credentials OAuth2 application in Xibo and returns
|
|
|
|
|
/// the generated client_id and client_secret.
|
|
|
|
|
/// </summary>
|
|
|
|
|
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<string, string>("name", appName),
|
|
|
|
|
new KeyValuePair<string, string>("clientId", Guid.NewGuid().ToString("N")),
|
|
|
|
|
new KeyValuePair<string, string>("confidential", "1"),
|
|
|
|
|
new KeyValuePair<string, string>("authCode", "0"),
|
|
|
|
|
new KeyValuePair<string, string>("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
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
|
|
|
|
|
/// </summary>
|
|
|
|
|
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<string, string>("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<string> GetTokenAsync(
|
|
|
|
|
string baseUrl,
|
|
|
|
|
string clientId,
|
|
|
|
|
string clientSecret,
|
|
|
|
|
HttpClient client)
|
|
|
|
|
{
|
|
|
|
|
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
|
|
|
|
var form = new FormUrlEncodedContent(new[]
|
|
|
|
|
{
|
|
|
|
|
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
|
|
|
|
new KeyValuePair<string, string>("client_id", clientId),
|
|
|
|
|
new KeyValuePair<string, string>("client_secret", clientSecret),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var response = await client.PostAsync(tokenUrl, form);
|
|
|
|
|
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
throw new XiboAuthException(
|
|
|
|
|
response.StatusCode switch
|
2026-02-12 15:24:25 -05:00
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
|
|
|
|
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
2026-02-12 15:24:25 -05:00
|
|
|
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
|
|
|
|
_ => $"Unexpected response: {(int)response.StatusCode}"
|
|
|
|
|
},
|
2026-02-25 08:05:44 -05:00
|
|
|
(int)response.StatusCode);
|
2026-02-12 15:24:25 -05:00
|
|
|
}
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
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)
|
2026-02-12 15:24:25 -05:00
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Xibo API call '{operation}' failed: {(int)response.StatusCode} — {body}");
|
2026-02-12 15:24:25 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 08:05:44 -05:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Result / exception types
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-02-12 15:24:25 -05:00
|
|
|
public class XiboTestResult
|
|
|
|
|
{
|
2026-02-25 08:05:44 -05:00
|
|
|
public bool IsValid { get; set; }
|
|
|
|
|
public string Message { get; set; } = string.Empty;
|
|
|
|
|
public int HttpStatus { get; set; }
|
2026-02-12 15:24:25 -05:00
|
|
|
}
|
2026-02-25 08:05:44 -05:00
|
|
|
|
|
|
|
|
public class XiboAuthException : Exception
|
|
|
|
|
{
|
|
|
|
|
public int HttpStatus { get; }
|
|
|
|
|
public XiboAuthException(string message, int httpStatus) : base(message)
|
|
|
|
|
=> HttpStatus = httpStatus;
|
|
|
|
|
}
|
|
|
|
|
|