feat: Implement container logs functionality in InstancesViewModel

- Added properties for managing container logs, including log entries, service filters, and auto-refresh options.
- Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel.
- Implemented log fetching logic with error handling and status messages.
- Integrated log display in the InstancesView with a dedicated logs panel.

feat: Enhance navigation to Instances page with auto-selection

- Added method to navigate to the Instances page and auto-select an instance based on abbreviation.

feat: Update SettingsViewModel to load and save Bitwarden configuration

- Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json.
- Added properties for Bitwarden instance project ID and connection status.
- Updated UI to reflect Bitwarden settings and connection status.

feat: Add advanced options for instance creation

- Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes.

feat: Improve InstanceDetailsWindow with pending setup banner

- Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret.

fix: Update appsettings.json to include Bitwarden configuration structure

- Added Bitwarden section to appsettings.json for storing configuration values.

chore: Update Docker Compose template with health checks

- Added health check configuration for web service in template.yml to ensure service availability.

refactor: Drop AppSettings table from database

- Removed AppSettings table and related migration files as part of database cleanup.

feat: Create ServiceLogEntry DTO for log management

- Added ServiceLogEntry class to represent individual log entries from Docker services.
This commit is contained in:
Matt Batchelder
2026-02-25 17:39:17 -05:00
parent a1c987ff21
commit 90eb649940
35 changed files with 1807 additions and 621 deletions

View File

@@ -1,255 +1,230 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bitwarden.Sdk;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Stores and retrieves secrets from Bitwarden Secrets Manager (machine account API).
/// Stores and retrieves secrets from Bitwarden Secrets Manager via the official Bitwarden C# SDK.
///
/// Configuration required in Settings:
/// Bitwarden.IdentityUrl defaults to https://identity.bitwarden.com
/// Bitwarden.ApiUrl defaults to https://api.bitwarden.com
/// Bitwarden.AccessToken machine account access token (sensitive)
/// Bitwarden.OrganizationId Bitwarden organisation that owns the project
/// Bitwarden.ProjectId project where new secrets are created
/// Configuration is read from <see cref="BitwardenOptions"/> (bound to appsettings.json → "Bitwarden").
///
/// The SDK state file is persisted to %APPDATA%/OTSSignsOrchestrator/bitwarden.state
/// so the SDK can cache its internal state across restarts.
/// </summary>
public class BitwardenSecretService : IBitwardenSecretService
public class BitwardenSecretService : IBitwardenSecretService, IDisposable
{
private readonly IHttpClientFactory _http;
private readonly SettingsService _settings;
private readonly IOptionsMonitor<BitwardenOptions> _optionsMonitor;
private readonly ILogger<BitwardenSecretService> _logger;
// Cached bearer token (refreshed per service lifetime — transient registration is assumed)
private string? _bearerToken;
// Lazily created on first use (per service instance — registered as Transient).
private BitwardenClient? _client;
private string? _clientAccessToken; // track which token the client was created with
private bool _disposed;
/// <summary>Always returns the latest config snapshot (reloaded when appsettings.json changes).</summary>
private BitwardenOptions Options => _optionsMonitor.CurrentValue;
public BitwardenSecretService(
IHttpClientFactory http,
SettingsService settings,
IOptionsMonitor<BitwardenOptions> optionsMonitor,
ILogger<BitwardenSecretService> logger)
{
_http = http;
_settings = settings;
_logger = logger;
_optionsMonitor = optionsMonitor;
_logger = logger;
}
// ─────────────────────────────────────────────────────────────────────────
// IBitwardenSecretService
// ─────────────────────────────────────────────────────────────────────────
public async Task<bool> IsConfiguredAsync()
public Task<bool> IsConfiguredAsync()
{
var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken);
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId);
return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId);
var opts = Options;
var configured = !string.IsNullOrWhiteSpace(opts.AccessToken)
&& !string.IsNullOrWhiteSpace(opts.OrganizationId);
return Task.FromResult(configured);
}
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
{
var bearer = await GetBearerTokenAsync();
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
var apiUrl = await GetApiUrlAsync();
var client = await GetClientAsync();
var orgId = GetOrgId();
var projectIds = GetProjectIds();
var client = CreateClient(bearer);
var body = new
{
organizationId = orgId,
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
key = key,
value = value,
note = note
};
var response = await client.PostAsJsonAsync($"{apiUrl}/secrets", body);
await EnsureSuccessAsync(response, "create secret");
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
return result.Id;
return result.Id.ToString();
}
public async Task<string> CreateInstanceSecretAsync(string key, string value, string note = "")
{
var client = await GetClientAsync();
var orgId = GetOrgId();
var projectIds = GetInstanceProjectIds();
var result = await Task.Run(() => client.Secrets.Create(orgId, key, value, note, projectIds));
_logger.LogInformation("Bitwarden instance secret created: key={Key}, id={Id}, project={Project}",
key, result.Id, Options.InstanceProjectId);
return result.Id.ToString();
}
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
{
var bearer = await GetBearerTokenAsync();
var apiUrl = await GetApiUrlAsync();
var client = CreateClient(bearer);
var response = await client.GetAsync($"{apiUrl}/secrets/{secretId}");
await EnsureSuccessAsync(response, "get secret");
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
var client = await GetClientAsync();
var result = await Task.Run(() => client.Secrets.Get(Guid.Parse(secretId)));
return new BitwardenSecret
{
Id = result.Id,
Id = result.Id.ToString(),
Key = result.Key,
Value = result.Value,
Note = result.Note ?? string.Empty,
CreationDate = result.CreationDate
CreationDate = result.CreationDate.DateTime
};
}
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
{
var bearer = await GetBearerTokenAsync();
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
var apiUrl = await GetApiUrlAsync();
var client = await GetClientAsync();
var orgId = GetOrgId();
var projectIds = GetProjectIds();
var client = CreateClient(bearer);
var body = new
{
organizationId = orgId,
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
key = key,
value = value,
note = note
};
var response = await client.PutAsJsonAsync($"{apiUrl}/secrets/{secretId}", body);
await EnsureSuccessAsync(response, "update secret");
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
}
public async Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "")
{
var client = await GetClientAsync();
var orgId = GetOrgId();
var projectIds = GetInstanceProjectIds();
await Task.Run(() => client.Secrets.Update(orgId, Guid.Parse(secretId), key, value, note, projectIds));
_logger.LogInformation("Bitwarden instance secret updated: key={Key}, id={Id}, project={Project}",
key, secretId, Options.InstanceProjectId);
}
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
{
var bearer = await GetBearerTokenAsync();
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
var apiUrl = await GetApiUrlAsync();
var client = CreateClient(bearer);
var client = await GetClientAsync();
var orgId = GetOrgId();
var result = await Task.Run(() => client.Secrets.List(orgId));
var response = await client.GetAsync($"{apiUrl}/organizations/{orgId}/secrets");
await EnsureSuccessAsync(response, "list secrets");
var result = await response.Content.ReadFromJsonAsync<BwsSecretsListResponse>();
return result?.Data?.Select(s => new BitwardenSecretSummary
return result.Data?.Select(s => new BitwardenSecretSummary
{
Id = s.Id,
Id = s.Id.ToString(),
Key = s.Key,
CreationDate = s.CreationDate
CreationDate = DateTime.MinValue
}).ToList() ?? new List<BitwardenSecretSummary>();
}
// ─────────────────────────────────────────────────────────────────────────
// Auth
// SDK client initialisation
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Exchanges the machine-account access token for a short-lived Bearer token.
/// The access token format is: 0.{tokenId}.{clientSecret}:{encKeyB64}
/// The client_id used is "machine.{tokenId}".
/// Returns an authenticated <see cref="BitwardenClient"/>, creating and logging in on first use.
/// </summary>
private async Task<string> GetBearerTokenAsync()
private async Task<BitwardenClient> GetClientAsync()
{
if (_bearerToken is not null)
return _bearerToken;
var opts = Options;
var rawToken = await _settings.GetAsync(SettingsService.BitwardenAccessToken)
?? throw new InvalidOperationException("Bitwarden AccessToken is not configured in Settings.");
var identityUrl = await GetIdentityUrlAsync();
// Parse token: "0.<tokenId>.<clientSecretAndKey>" — split off the first two segments
var parts = rawToken.Split('.', 3);
if (parts.Length < 3)
throw new FormatException(
"Bitwarden access token has unexpected format. Expected: 0.<tokenId>.<rest>");
var tokenId = parts[1];
var clientSecret = parts[2]; // may contain ":base64key" suffix — include all of it
var client = _http.CreateClient("Bitwarden");
var form = new FormUrlEncodedContent(new[]
// If credentials changed since the client was created, tear it down so we re-auth
if (_client is not null && _clientAccessToken != opts.AccessToken)
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", $"machine.{tokenId}"),
new KeyValuePair<string, string>("client_secret", clientSecret),
new KeyValuePair<string, string>("scope", "api.secrets"),
});
_logger.LogInformation("Bitwarden credentials changed — recreating SDK client");
_client.Dispose();
_client = null;
}
var response = await client.PostAsync($"{identityUrl}/connect/token", form);
await EnsureSuccessAsync(response, "authenticate with Bitwarden identity");
if (_client is not null)
return _client;
var token = await response.Content.ReadFromJsonAsync<BwsTokenResponse>()
?? throw new InvalidOperationException("Empty token response from Bitwarden.");
if (string.IsNullOrWhiteSpace(opts.AccessToken))
throw new InvalidOperationException("Bitwarden AccessToken is not configured. Set it in Settings → Bitwarden.");
_bearerToken = token.AccessToken;
_logger.LogInformation("Bitwarden bearer token acquired.");
return _bearerToken;
var accessToken = opts.AccessToken;
var apiUrl = (opts.ApiUrl ?? "https://api.bitwarden.com").TrimEnd('/');
var identityUrl = (opts.IdentityUrl ?? "https://identity.bitwarden.com").TrimEnd('/');
var sdkSettings = new BitwardenSettings { ApiUrl = apiUrl, IdentityUrl = identityUrl };
var client = new BitwardenClient(sdkSettings);
await Task.Run(() => client.Auth.LoginAccessToken(accessToken, GetStateFilePath()));
_logger.LogInformation("Bitwarden SDK client initialised and authenticated.");
_client = client;
_clientAccessToken = accessToken;
return _client;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private async Task<string> GetIdentityUrlAsync()
=> (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/');
private async Task<string> GetApiUrlAsync()
=> (await _settings.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com")).TrimEnd('/');
private static HttpClient CreateClient(string bearerToken)
private Guid GetOrgId()
{
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", bearerToken);
return client;
var orgId = Options.OrganizationId;
if (string.IsNullOrWhiteSpace(orgId))
throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
return Guid.Parse(orgId);
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
private Guid[] GetProjectIds()
{
if (!response.IsSuccessStatusCode)
var projectId = Options.ProjectId;
if (string.IsNullOrWhiteSpace(projectId))
throw new InvalidOperationException(
"Bitwarden ProjectId is required. Set it in Settings → Bitwarden.");
return new[] { Guid.Parse(projectId) };
}
/// <summary>
/// Returns the project IDs array for instance-level secrets.
/// Uses <see cref="BitwardenOptions.InstanceProjectId"/> when configured,
/// otherwise falls back to the default <see cref="BitwardenOptions.ProjectId"/>.
/// </summary>
private Guid[] GetInstanceProjectIds()
{
var instanceProjectId = Options.InstanceProjectId;
if (!string.IsNullOrWhiteSpace(instanceProjectId))
{
var body = await response.Content.ReadAsStringAsync();
throw new HttpRequestException(
$"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}");
_logger.LogDebug("Using instance Bitwarden project: {ProjectId}", instanceProjectId);
return new[] { Guid.Parse(instanceProjectId) };
}
// Fall back to the default config project
return GetProjectIds();
}
/// <summary>
/// Returns the path where the SDK stores its state between sessions.
/// Stored under %APPDATA%/OTSSignsOrchestrator/bitwarden.state.
/// </summary>
private static string GetStateFilePath()
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OTSSignsOrchestrator");
Directory.CreateDirectory(dir);
return Path.Combine(dir, "bitwarden.state");
}
// ─────────────────────────────────────────────────────────────────────────
// IDisposable
// ─────────────────────────────────────────────────────────────────────────
public void Dispose()
{
if (!_disposed)
{
_client?.Dispose();
_disposed = true;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Internal DTOs
// ─────────────────────────────────────────────────────────────────────────
private sealed class BwsTokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
}
private sealed class BwsSecretResponse
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("organizationId")]
public string OrganizationId { get; set; } = string.Empty;
[JsonPropertyName("key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
[JsonPropertyName("note")]
public string? Note { get; set; }
[JsonPropertyName("creationDate")]
public DateTime CreationDate { get; set; }
}
private sealed class BwsSecretsListResponse
{
[JsonPropertyName("data")]
public List<BwsSecretResponse>? Data { get; set; }
}
}

View File

@@ -111,7 +111,10 @@ public class ComposeRenderService
var export = (ctx.NfsExport ?? string.Empty).Trim('/');
var folder = (ctx.NfsExportFolder ?? string.Empty).Trim('/');
var path = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
return $":/{path}";
// When path is empty the prefix must be ":" with no trailing slash — the template
// already supplies the leading "/" before {{ABBREV}}, so ":" + "/ots/..." = ":/ots/..."
// (correct). Returning ":/" would produce "://ots/..." which Docker rejects.
return string.IsNullOrEmpty(path) ? ":" : $":/{path}";
}
/// <summary>
@@ -195,6 +198,12 @@ public class ComposeRenderService
- {{ABBREV}}-cms-ca-certs:/var/www/cms/ca-certs
ports:
- "{{HOST_HTTP_PORT}}:80"
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 5 http://web:80/about | grep -Eo 'v?[0-9]+(\\.[0-9]+)+' >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
{{ABBREV}}-net:
aliases:
@@ -236,6 +245,9 @@ public class ComposeRenderService
PANGOLIN_ENDPOINT: {{PANGOLIN_ENDPOINT}}
NEWT_ID: {{NEWT_ID}}
NEWT_SECRET: {{NEWT_SECRET}}
depends_on:
{{ABBREV}}-web:
condition: service_healthy
networks:
{{ABBREV}}-net: {}
deploy:

View File

@@ -16,6 +16,13 @@ public interface IBitwardenSecretService
/// <returns>The ID of the created secret.</returns>
Task<string> CreateSecretAsync(string key, string value, string note = "");
/// <summary>
/// Creates a new secret in the instance Bitwarden project (falls back to default project if not configured).
/// Use this for instance-level secrets such as DB passwords and Newt credentials.
/// </summary>
/// <returns>The ID of the created secret.</returns>
Task<string> CreateInstanceSecretAsync(string key, string value, string note = "");
/// <summary>
/// Retrieves a secret by its Bitwarden ID.
/// </summary>
@@ -26,6 +33,11 @@ public interface IBitwardenSecretService
/// </summary>
Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
/// <summary>
/// Updates the value of an existing instance-level secret in place (uses instance project if configured).
/// </summary>
Task UpdateInstanceSecretAsync(string secretId, string key, string value, string note = "");
/// <summary>
/// Lists all secrets in the configured project.
/// </summary>

View File

@@ -3,6 +3,9 @@ using OTSSignsOrchestrator.Core.Models.DTOs;
namespace OTSSignsOrchestrator.Core.Services;
// Re-export for convenience so consumers only need one using
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
/// <summary>
/// Abstraction for Docker CLI stack operations (deploy, remove, list, inspect).
/// Implementations may use local docker CLI or SSH-based remote execution.
@@ -87,6 +90,13 @@ public interface IDockerCliService
/// <paramref name="oldSecretName"/> when null, keeping the same /run/secrets/ filename).
/// </summary>
Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null);
/// <summary>
/// Fetches the last <paramref name="tailLines"/> log lines from a Docker Swarm service.
/// If <paramref name="serviceName"/> is null, fetches logs from all services in the stack.
/// Returns parsed log entries sorted by timestamp ascending.
/// </summary>
Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200);
}
public class StackInfo

View File

@@ -99,10 +99,22 @@ public class InstanceService
_logger.LogInformation("Fetching template repo: {RepoUrl}", repoUrl);
var templateConfig = await _git.FetchAsync(repoUrl, repoPat);
// ── 1b. Remove any stale stack that might hold references to old secrets
_logger.LogInformation("Removing stale stack (if any): {StackName}", stackName);
await _docker.RemoveStackAsync(stackName);
await Task.Delay(2000);
// ── 1b. Remove stale stack (and optionally its cached volumes) ────
// docker stack rm alone leaves named volumes behind; those volumes
// retain their old driver_opts and Docker re-uses them on the next
// deploy, ignoring the new (correct) options in the compose file.
// PurgeStaleVolumes must be explicitly opted into to avoid accidental data loss.
if (dto.PurgeStaleVolumes)
{
_logger.LogInformation("Purging stale stack and volumes (PurgeStaleVolumes=true): {StackName}", stackName);
await _docker.RemoveStackVolumesAsync(stackName);
}
else
{
_logger.LogInformation("Removing stale stack (if any): {StackName}", stackName);
await _docker.RemoveStackAsync(stackName);
await Task.Delay(2000);
}
// ── 2. Generate MySQL credentials ──────────────────────────────
var mysqlPassword = GenerateRandomPassword(32);
@@ -129,12 +141,11 @@ public class InstanceService
throw new InvalidOperationException($"MySQL database/user setup failed: {mysqlMsg}");
_logger.LogInformation("MySQL setup complete: {Message}", mysqlMsg);
// ── 2c. Persist password (encrypted) for future redeploys ────────
// ── 2c. Persist password for future redeploys ────────
await _settings.SetAsync(
SettingsService.InstanceMySqlPassword(abbrev), mysqlPassword,
SettingsService.CatInstance, isSensitive: true);
await _db.SaveChangesAsync();
_logger.LogInformation("MySQL password stored in settings for instance {Abbrev}", abbrev);
_logger.LogInformation("MySQL password stored in Bitwarden for instance {Abbrev}", abbrev);
// ── 3. Read settings ────────────────────────────────────────────
var mySqlHost = mySqlHostValue;
@@ -238,11 +249,7 @@ public class InstanceService
+ "(2) NFS export has root_squash enabled — set 'No mapping' / no_root_squash on the NFS server.");
}
// ── 6. Remove stale NFS volumes ─────────────────────────────────
_logger.LogInformation("Removing stale NFS volumes for stack {StackName}", stackName);
await _docker.RemoveStackVolumesAsync(stackName);
// ── 7. Deploy stack ─────────────────────────────────────────────
// ── 6. Deploy stack ─────────────────────────────────────────────
var deployResult = await _docker.DeployStackAsync(stackName, composeYaml);
if (!deployResult.Success)
throw new InvalidOperationException($"Stack deployment failed: {deployResult.ErrorMessage}");
@@ -258,13 +265,15 @@ public class InstanceService
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
// ── 8. Post-instance init (fire-and-forget background task) ──────
// Waits for Xibo to be ready then creates admin user, OAuth app, and sets theme.
var instanceUrl = $"https://{cmsServerName}";
_ = Task.Run(async () => await _postInit.RunAsync(abbrev, instanceUrl));
// ── 7. Return result — post-init will be triggered by the UI ──────
// after the user creates an OAuth2 app in the Xibo web UI and supplies
// the client_id and client_secret.
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
deployResult.InstanceUrl = instanceUrl;
deployResult.Abbrev = abbrev;
deployResult.ServiceCount = 4;
deployResult.Message = "Instance deployed successfully. Post-install setup is running in background.";
deployResult.Message = "Instance deployed successfully. Complete post-install setup by providing OAuth credentials.";
return deployResult;
}
catch (Exception ex)

View File

@@ -1,8 +1,7 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using System.Text.RegularExpressions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Data;
namespace OTSSignsOrchestrator.Core.Services;
@@ -10,15 +9,22 @@ namespace OTSSignsOrchestrator.Core.Services;
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
/// <list type="number">
/// <item>Waits for the Xibo web service to become available.</item>
/// <item>Authenticates using the OAuth2 application credentials supplied by the user.</item>
/// <item>Creates the OTS admin user with a random password.</item>
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
/// <item>Activates the <c>otssigns</c> theme.</item>
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
/// <item>Deletes the default <c>xibo_admin</c> account.</item>
/// </list>
///
/// Invoked as a background fire-and-forget task from <see cref="InstanceService.CreateInstanceAsync"/>.
/// The user must first create an OAuth2 application (client_credentials) in the
/// Xibo web UI using the default <c>xibo_admin / password</c> account that ships
/// with every new Xibo CMS instance.
///
/// Invoked from the Create Instance UI after the user supplies the OAuth credentials.
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
/// </summary>
/// </summary>
public class PostInstanceInitService
{
private readonly IServiceProvider _services;
@@ -41,9 +47,14 @@ public class PostInstanceInitService
/// <summary>
/// Executes the post-instance initialisation sequence.
/// Intended to be called as a background task after stack deployment succeeds.
/// Called from the UI after the user supplies OAuth2 client credentials.
/// </summary>
public async Task RunAsync(string abbrev, string instanceUrl, CancellationToken ct = default)
public async Task RunAsync(
string abbrev,
string instanceUrl,
string clientId,
string clientSecret,
CancellationToken ct = default)
{
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
@@ -53,24 +64,6 @@ public class PostInstanceInitService
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
// ── Validate Bitwarden is configured ────────────────────────────
if (!await bws.IsConfiguredAsync())
{
_logger.LogWarning(
"[PostInit] Bitwarden is not configured — credentials will NOT be stored in Bitwarden. " +
"Configure Settings → Bitwarden to enable secret storage.");
}
// ── Read bootstrap credentials ───────────────────────────────────
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId);
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret);
if (string.IsNullOrWhiteSpace(bootstrapClientId) || string.IsNullOrWhiteSpace(bootstrapClientSecret))
throw new InvalidOperationException(
"Xibo bootstrap OAuth2 credentials are not configured. " +
"Set Settings → Xibo Bootstrap Client ID / Secret to enable post-instance setup.");
// ── 1. Wait for Xibo readiness ────────────────────────────────────
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
@@ -79,74 +72,158 @@ public class PostInstanceInitService
throw new TimeoutException(
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
// ── 2. Generate credentials ───────────────────────────────────────
// ── 2. Authenticate with user-supplied OAuth2 credentials ─────────
_logger.LogInformation("[PostInit] Obtaining access token via client_credentials");
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
// ── 3. Generate credentials ───────────────────────────────────────
var adminUsername = $"ots-admin-{abbrev}";
var adminPassword = GeneratePassword(24);
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
// ── 3. Create OTS admin user ──────────────────────────────────────
// ── 4. Create OTS admin group ─────────────────────────────────────
var adminGroupName = $"ots-admins-{abbrev}";
_logger.LogInformation("[PostInit] Creating OTS admin group '{GroupName}'", adminGroupName);
var adminGroupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, adminGroupName);
// ── 5. Create OTS admin user ──────────────────────────────────────
_logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername);
int userId = await xibo.CreateAdminUserAsync(
instanceUrl, bootstrapClientId, bootstrapClientSecret,
adminUsername, adminPassword, adminEmail);
instanceUrl, accessToken,
adminUsername, adminPassword, adminEmail, adminGroupId);
// ── 4. Register dedicated OAuth2 application ──────────────────────
// ── 5a. Assign admin user to OTS admin group ──────────────────────
_logger.LogInformation("[PostInit] Assigning '{Username}' to group '{GroupName}'", adminUsername, adminGroupName);
await xibo.AssignUserToGroupAsync(instanceUrl, accessToken, adminGroupId, userId);
// ── 6. Register dedicated OAuth2 application for OTS ──────────────
_logger.LogInformation("[PostInit] Registering OTS OAuth2 application");
var (oauthClientId, oauthClientSecret) = await xibo.RegisterOAuthClientAsync(
instanceUrl, bootstrapClientId, bootstrapClientSecret,
appName: $"OTS Signs — {abbrev.ToUpperInvariant()}");
var (otsClientId, otsClientSecret) = await xibo.RegisterOAuthClientAsync(
instanceUrl, accessToken,
$"OTS Signs — {abbrev.ToUpperInvariant()}");
// ── 5. Set theme ──────────────────────────────────────────────────
// ── 6. Set theme ──────────────────────────────────────────────────
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
await xibo.SetThemeAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, "otssigns");
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
// ── 6. Store credentials in Bitwarden ─────────────────────────────
if (await bws.IsConfiguredAsync())
{
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
// ── 7. Store credentials in Bitwarden ─────────────────────────────
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
var adminSecretId = await bws.CreateSecretAsync(
key: $"{abbrev}/xibo-admin-password",
value: adminPassword,
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
var adminSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-admin-password",
value: adminPassword,
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
var oauthSecretId = await bws.CreateSecretAsync(
key: $"{abbrev}/xibo-oauth-secret",
value: oauthClientSecret,
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {oauthClientId}");
var oauthSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-oauth-secret",
value: otsClientSecret,
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {otsClientId}");
// Persist Bitwarden secret IDs + OAuth client ID in AppSettings for later retrieval
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
SettingsService.CatInstance, isSensitive: false);
}
else
{
// No Bitwarden — fall back to encrypted AppSettings (less ideal for secrets)
_logger.LogWarning(
"[PostInit] Bitwarden not configured. Storing admin password locally (encrypted). " +
"OAuth client secret is discarded — re-register from the instance details modal.");
// Persist Bitwarden secret IDs + OAuth client ID as config settings
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), otsClientId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(
$"Instance.{abbrev}.AdminPassword", adminPassword,
SettingsService.CatInstance, isSensitive: true);
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
SettingsService.CatInstance, isSensitive: false);
}
await db.SaveChangesAsync(ct);
// ── 8. Remove the default xibo_admin account ──────────────────────
_logger.LogInformation("[PostInit] Removing default xibo_admin user");
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
await xibo.DeleteUserAsync(instanceUrl, accessToken, xiboAdminId);
_logger.LogInformation("[PostInit] xibo_admin user removed (userId={UserId})", xiboAdminId);
_logger.LogInformation(
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
abbrev, adminUsername, oauthClientId);
abbrev, adminUsername, otsClientId);
}
catch (Exception ex)
{
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
// Do NOT rethrow — the stack is already deployed; we don't want to break the deployment result.
throw; // Propagate to calling UI so the user sees the error
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialise using caller-supplied OAuth credentials (no new app registration)
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Initialises a Xibo CMS instance using OAuth2 credentials supplied by the user.
/// Unlike <see cref="RunAsync"/>, this method does NOT register a new OAuth application;
/// instead it stores the caller-supplied credentials for future API operations.
/// Steps: wait → authenticate → create OTS admin → set theme → remove xibo_admin → store credentials.
/// </summary>
public async Task InitializeWithOAuthAsync(
string abbrev,
string instanceUrl,
string clientId,
string clientSecret,
CancellationToken ct = default)
{
_logger.LogInformation("[PostInit] Starting initialisation for {Abbrev} ({Url})", abbrev, instanceUrl);
try
{
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
// ── 1. Wait for Xibo readiness ────────────────────────────────────
_logger.LogInformation("[PostInit] Waiting for Xibo at {Url}...", instanceUrl);
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
if (!ready)
throw new TimeoutException(
$"Xibo at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
// ── 2. Authenticate with caller-supplied OAuth2 credentials ───────
_logger.LogInformation("[PostInit] Obtaining access token");
var accessToken = await xibo.LoginAsync(instanceUrl, clientId, clientSecret);
// ── 3. Generate OTS admin credentials ─────────────────────────────
var adminUsername = $"ots-admin-{abbrev}";
var adminPassword = GeneratePassword(24);
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
// ── 4. Rename built-in xibo_admin to OTS admin ───────────────────
_logger.LogInformation("[PostInit] Looking up xibo_admin user");
var xiboAdminId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, "xibo_admin");
_logger.LogInformation("[PostInit] Updating xibo_admin (id={Id}) → '{Username}'", xiboAdminId, adminUsername);
await xibo.UpdateUserAsync(instanceUrl, accessToken, xiboAdminId, adminUsername, adminPassword, adminEmail);
// ── 5. Set theme ──────────────────────────────────────────────────
_logger.LogInformation("[PostInit] Setting theme to 'otssigns'");
await xibo.SetThemeAsync(instanceUrl, accessToken, "otssigns");
// ── 6. Store admin password in Bitwarden ──────────────────────────
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
var adminSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-admin-password",
value: adminPassword,
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
SettingsService.CatInstance, isSensitive: false);
// ── 7. Store caller-supplied OAuth credentials in Bitwarden ───────
var oauthSecretId = await bws.CreateInstanceSecretAsync(
key: $"{abbrev}/xibo-oauth-secret",
value: clientSecret,
note: $"Xibo CMS OAuth2 client secret for instance {abbrev}. ClientId: {clientId}");
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
SettingsService.CatInstance, isSensitive: false);
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), clientId,
SettingsService.CatInstance, isSensitive: false);
_logger.LogInformation("[PostInit] xibo_admin removed");
_logger.LogInformation("[PostInit] Initialisation complete for {Abbrev}", abbrev);
}
catch (Exception ex)
{
_logger.LogError(ex, "[PostInit] Initialisation failed for {Abbrev}: {Message}", abbrev, ex.Message);
throw;
}
}
@@ -165,59 +242,50 @@ public class PostInstanceInitService
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
// Get bootstrap credentials to authenticate against Xibo API
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId)
?? throw new InvalidOperationException("Xibo bootstrap client not configured.");
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret)
?? throw new InvalidOperationException("Xibo bootstrap client secret not configured.");
// Xibo user ID: we store it as the numeric userId in the username convention
// We need to look it up from the admin username. For now derive from OTS convention.
var adminUsername = $"ots-admin-{abbrev}";
var newPassword = GeneratePassword(24);
// We need to look up the userId — use the Xibo API (list users, find by userName)
var userId = await GetXiboUserIdAsync(xibo, instanceUrl, bootstrapClientId, bootstrapClientSecret, adminUsername);
// Log in using the stored OTS OAuth2 client credentials
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId))
throw new InvalidOperationException(
$"No OAuth credentials found for instance '{abbrev}'. Was post-init completed?");
await xibo.RotateUserPasswordAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, userId, newPassword);
var oauthSecret = await bws.GetSecretAsync(oauthSecretId);
var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value);
// Update Bitwarden secret if available
if (await bws.IsConfiguredAsync())
// Look up the OTS admin user ID
var userId = await xibo.GetUserIdByNameAsync(instanceUrl, accessToken, adminUsername);
await xibo.RotateUserPasswordAsync(instanceUrl, accessToken, userId, newPassword);
// Update Bitwarden secret
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(secretId))
{
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(secretId))
{
await bws.UpdateSecretAsync(secretId,
key: $"{abbrev}/xibo-admin-password",
value: newPassword,
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
}
else
{
// Secret doesn't exist yet in Bitwarden — create it now
var newSecretId = await bws.CreateSecretAsync(
$"{abbrev}/xibo-admin-password", newPassword,
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
SettingsService.CatInstance, isSensitive: false);
}
await bws.UpdateInstanceSecretAsync(secretId,
key: $"{abbrev}/xibo-admin-password",
value: newPassword,
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
}
else
{
// Fallback: encrypted AppSettings
await settings.SetAsync($"Instance.{abbrev}.AdminPassword", newPassword,
SettingsService.CatInstance, isSensitive: true);
// Secret doesn't exist yet in Bitwarden — create it now
var newSecretId = await bws.CreateInstanceSecretAsync(
$"{abbrev}/xibo-admin-password", newPassword,
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
SettingsService.CatInstance, isSensitive: false);
}
await db.SaveChangesAsync();
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
return newPassword;
}
/// <summary>
/// Returns the stored admin password for an instance (from Bitwarden or local AppSettings).
/// Returns the stored admin password for an instance from Bitwarden.
/// </summary>
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
{
@@ -230,39 +298,32 @@ public class PostInstanceInitService
string? adminPassword = null;
string? oauthClientSecret = null;
if (await bws.IsConfiguredAsync())
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(adminSecretId))
{
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(adminSecretId))
try
{
try
{
var secret = await bws.GetSecretAsync(adminSecretId);
adminPassword = secret.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
}
var secret = await bws.GetSecretAsync(adminSecretId);
adminPassword = secret.Value;
}
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(oauthSecretId))
catch (Exception ex)
{
try
{
var secret = await bws.GetSecretAsync(oauthSecretId);
oauthClientSecret = secret.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
}
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
}
}
else
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (!string.IsNullOrWhiteSpace(oauthSecretId))
{
adminPassword = await settings.GetAsync($"Instance.{abbrev}.AdminPassword");
try
{
var secret = await bws.GetSecretAsync(oauthSecretId);
oauthClientSecret = secret.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
}
}
return new InstanceCredentials
@@ -284,50 +345,109 @@ public class PostInstanceInitService
return RandomNumberGenerator.GetString(chars, length);
}
private static async Task<int> GetXiboUserIdAsync(
XiboApiService xibo,
string instanceUrl,
string clientId,
string clientSecret,
string targetUsername)
// ─────────────────────────────────────────────────────────────────────────
// Import existing instance secrets on startup
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Scans all Bitwarden secrets for existing instance-level credentials
/// (matching the <c>{abbrev}/xibo-admin-password</c> and <c>{abbrev}/xibo-oauth-secret</c>
/// naming convention) and imports their mappings into the config settings so
/// the app knows about them without a manual re-provisioning step.
/// Safe to call on every startup — existing mappings are never overwritten.
/// </summary>
public async Task ImportExistingInstanceSecretsAsync()
{
// This is a lightweight HTTP GET to fetch the user list and find by name.
// We use HttpClient directly since XiboApiService doesn't expose this as a standalone call.
using var http = new System.Net.Http.HttpClient();
var baseUrl = instanceUrl.TrimEnd('/');
// Get token first via the TestConnectionAsync pattern
var form = new FormUrlEncodedContent(new[]
try
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", clientSecret),
});
using var scope = _services.CreateScope();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var tokenResp = await http.PostAsync($"{baseUrl}/api/authorize/access_token", form);
tokenResp.EnsureSuccessStatusCode();
if (!await bws.IsConfiguredAsync())
{
_logger.LogDebug("[Import] Bitwarden not configured — skipping instance secret import");
return;
}
using var tokenDoc = await System.Text.Json.JsonDocument.ParseAsync(
await tokenResp.Content.ReadAsStreamAsync());
var token = tokenDoc.RootElement.GetProperty("access_token").GetString()!;
var allSecrets = await bws.ListSecretsAsync();
var imported = 0;
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
foreach (var summary in allSecrets)
{
// ── Admin password pattern: {abbrev}/xibo-admin-password ──
var adminMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-admin-password$");
if (adminMatch.Success)
{
var abbrev = adminMatch.Groups[1].Value;
var existing = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
if (string.IsNullOrWhiteSpace(existing))
{
await settings.SetAsync(
SettingsService.InstanceAdminPasswordSecretId(abbrev),
summary.Id, SettingsService.CatInstance);
imported++;
_logger.LogInformation(
"[Import] Imported admin password secret for instance {Abbrev} (id={Id})",
abbrev, summary.Id);
}
continue;
}
var usersResp = await http.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(targetUsername)}");
usersResp.EnsureSuccessStatusCode();
// ── OAuth secret pattern: {abbrev}/xibo-oauth-secret ──
var oauthMatch = Regex.Match(summary.Key, @"^([a-zA-Z0-9_-]+)/xibo-oauth-secret$");
if (oauthMatch.Success)
{
var abbrev = oauthMatch.Groups[1].Value;
var existing = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
if (string.IsNullOrWhiteSpace(existing))
{
await settings.SetAsync(
SettingsService.InstanceOAuthSecretId(abbrev),
summary.Id, SettingsService.CatInstance);
imported++;
_logger.LogInformation(
"[Import] Imported OAuth secret for instance {Abbrev} (id={Id})",
abbrev, summary.Id);
using var usersDoc = await System.Text.Json.JsonDocument.ParseAsync(
await usersResp.Content.ReadAsStreamAsync());
foreach (var user in usersDoc.RootElement.EnumerateArray())
{
var name = user.GetProperty("userName").GetString();
if (string.Equals(name, targetUsername, StringComparison.OrdinalIgnoreCase))
return user.GetProperty("userId").GetInt32();
// Try to extract the OAuth client_id from the secret's Note field
try
{
var full = await bws.GetSecretAsync(summary.Id);
var cidMatch = Regex.Match(full.Note ?? "", @"ClientId:\s*(\S+)");
if (cidMatch.Success)
{
var existingCid = await settings.GetAsync(
SettingsService.InstanceOAuthClientId(abbrev));
if (string.IsNullOrWhiteSpace(existingCid))
{
await settings.SetAsync(
SettingsService.InstanceOAuthClientId(abbrev),
cidMatch.Groups[1].Value, SettingsService.CatInstance);
_logger.LogInformation(
"[Import] Imported OAuth client ID for instance {Abbrev}", abbrev);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"[Import] Could not fetch full OAuth secret for {Abbrev} to extract client ID",
abbrev);
}
}
}
}
if (imported > 0)
_logger.LogInformation("[Import] Imported {Count} instance secret mapping(s) from Bitwarden", imported);
else
_logger.LogDebug("[Import] No new instance secrets to import");
}
catch (Exception ex)
{
_logger.LogError(ex, "[Import] Failed to import existing instance secrets from Bitwarden");
}
throw new InvalidOperationException(
$"Xibo user '{targetUsername}' not found. Post-instance init may not have completed yet.");
}
}

View File

@@ -1,21 +1,20 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Reads and writes typed application settings from the AppSetting table.
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
/// Reads and writes application settings from Bitwarden Secrets Manager.
/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/".
/// The secret's Note field stores metadata (category|isSensitive).
/// </summary>
public class SettingsService
{
private readonly XiboContext _db;
private readonly IDataProtector _protector;
private readonly IBitwardenSecretService _bws;
private readonly ILogger<SettingsService> _logger;
/// <summary>Prefix applied to all config secret keys in Bitwarden.</summary>
private const string KeyPrefix = "ots-config/";
// ── Category constants ─────────────────────────────────────────────────
public const string CatGit = "Git";
public const string CatMySql = "MySql";
@@ -68,14 +67,6 @@ public class SettingsService
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
// Bitwarden Secrets Manager
public const string CatBitwarden = "Bitwarden";
public const string BitwardenIdentityUrl = "Bitwarden.IdentityUrl";
public const string BitwardenApiUrl = "Bitwarden.ApiUrl";
public const string BitwardenAccessToken = "Bitwarden.AccessToken";
public const string BitwardenOrganizationId = "Bitwarden.OrganizationId";
public const string BitwardenProjectId = "Bitwarden.ProjectId";
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
public const string CatXibo = "Xibo";
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
@@ -84,7 +75,6 @@ public class SettingsService
// Instance-specific (keyed by abbreviation)
/// <summary>
/// Builds a per-instance settings key for the MySQL password.
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
/// </summary>
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
@@ -95,79 +85,169 @@ public class SettingsService
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
public const string CatInstance = "Instance";
// ── In-memory cache of secrets (loaded on first access) ────────────────
// Maps Bitwarden secret key (with prefix) → (id, value)
// Static so the cache is shared across all transient SettingsService instances.
private static Dictionary<string, (string Id, string Value)>? s_cache;
public SettingsService(
XiboContext db,
IDataProtectionProvider dataProtection,
IBitwardenSecretService bws,
ILogger<SettingsService> logger)
{
_db = db;
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
_bws = bws;
_logger = logger;
}
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
/// <summary>Get a single setting value from Bitwarden.</summary>
public async Task<string?> GetAsync(string key)
{
var setting = await _db.AppSettings.FindAsync(key);
if (setting == null) return null;
return setting.IsSensitive && setting.Value != null
? Unprotect(setting.Value)
: setting.Value;
var cache = await EnsureCacheAsync();
var bwKey = KeyPrefix + key;
if (!cache.TryGetValue(bwKey, out var entry))
return null;
// Treat single-space sentinel as empty (used to work around SDK marshalling limitation)
return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value;
}
/// <summary>Get a setting with a fallback default.</summary>
public async Task<string> GetAsync(string key, string defaultValue)
=> await GetAsync(key) ?? defaultValue;
/// <summary>Set a single setting, encrypting if sensitive.</summary>
/// <summary>Set a single setting in Bitwarden (creates or updates).</summary>
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
{
var setting = await _db.AppSettings.FindAsync(key);
if (setting == null)
{
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
_db.AppSettings.Add(setting);
}
var cache = await EnsureCacheAsync();
var bwKey = KeyPrefix + key;
var note = $"{category}|{(isSensitive ? "sensitive" : "plain")}";
// Use a single space for empty/null values — the Bitwarden SDK native FFI
// cannot marshal empty strings reliably.
var safeValue = string.IsNullOrEmpty(value) ? " " : value;
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
setting.IsSensitive = isSensitive;
setting.Category = category;
setting.UpdatedAt = DateTime.UtcNow;
if (cache.TryGetValue(bwKey, out var existing))
{
// Update existing secret
await _bws.UpdateSecretAsync(existing.Id, bwKey, safeValue, note);
cache[bwKey] = (existing.Id, safeValue);
s_cache = cache;
}
else if (!string.IsNullOrWhiteSpace(value))
{
// Only create new secrets when there is an actual value to store
var newId = await _bws.CreateSecretAsync(bwKey, safeValue, note);
cache[bwKey] = (newId, safeValue);
s_cache = cache;
}
}
/// <summary>Save multiple settings in a single transaction.</summary>
/// <summary>Save multiple settings in a batch.</summary>
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
{
var count = 0;
var errors = new List<string>();
foreach (var (key, value, category, isSensitive) in settings)
await SetAsync(key, value, category, isSensitive);
{
try
{
await SetAsync(key, value, category, isSensitive);
count++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
errors.Add(key);
}
}
await _db.SaveChangesAsync();
_logger.LogInformation("Saved {Count} setting(s)",
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
_logger.LogInformation("Saved {Count} setting(s) to Bitwarden", count);
if (errors.Count > 0)
throw new AggregateException(
$"Failed to save {errors.Count} setting(s): {string.Join(", ", errors)}");
}
/// <summary>Get all settings in a category (values decrypted).</summary>
/// <summary>Get all settings in a category (by examining cached keys).</summary>
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
{
var settings = await _db.AppSettings
.Where(s => s.Category == category)
.ToListAsync();
var cache = await EnsureCacheAsync();
var prefix = KeyPrefix + category + ".";
var result = new Dictionary<string, string?>();
return settings.ToDictionary(
s => s.Key,
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
foreach (var (bwKey, entry) in cache)
{
if (bwKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
// Strip the "ots-config/" prefix to return the original key
var originalKey = bwKey[KeyPrefix.Length..];
result[originalKey] = entry.Value;
}
}
return result;
}
private string? Unprotect(string protectedValue)
/// <summary>
/// Invalidates the in-memory cache so next access re-fetches from Bitwarden.
/// </summary>
public void InvalidateCache() => s_cache = null;
/// <summary>
/// Pre-loads the settings cache from Bitwarden.
/// Call once at startup so settings are available immediately.
/// </summary>
public async Task PreloadCacheAsync()
{
InvalidateCache();
await EnsureCacheAsync();
}
// ─────────────────────────────────────────────────────────────────────────
// Cache management
// ─────────────────────────────────────────────────────────────────────────
private async Task<Dictionary<string, (string Id, string Value)>> EnsureCacheAsync()
{
if (s_cache is not null)
return s_cache;
var cache = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
// Skip loading if Bitwarden is not yet configured (normal on first run)
if (!await _bws.IsConfiguredAsync())
{
_logger.LogInformation("Bitwarden is not configured yet — settings will be available after setup");
s_cache = cache;
return s_cache;
}
try
{
return _protector.Unprotect(protectedValue);
// List all secrets, then fetch full value for those matching our prefix
var summaries = await _bws.ListSecretsAsync();
var configSecrets = summaries
.Where(s => s.Key.StartsWith(KeyPrefix, StringComparison.OrdinalIgnoreCase))
.ToList();
_logger.LogInformation("Loading {Count} config secrets from Bitwarden", configSecrets.Count);
foreach (var summary in configSecrets)
{
try
{
var full = await _bws.GetSecretAsync(summary.Id);
cache[full.Key] = (full.Id, full.Value);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load secret {Key} ({Id})", summary.Key, summary.Id);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
return null;
_logger.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured");
}
s_cache = cache;
return s_cache;
}
}

View File

@@ -12,10 +12,10 @@ 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.
/// 1. After a new instance is deployed, <see cref="PostInstanceInitService"/> calls
/// <see cref="LoginAsync"/> 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.
/// </summary>
public class XiboApiService
{
@@ -74,35 +74,90 @@ public class XiboApiService
}
}
// ─────────────────────────────────────────────────────────────────────────
// Session login
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Obtains a Bearer access token using the OAuth2 <c>client_credentials</c> grant.
/// The caller must have previously created an OAuth2 application in the Xibo CMS
/// admin UI and provide the resulting <paramref name="clientId"/> and
/// <paramref name="clientSecret"/>.
/// </summary>
public async Task<string> 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<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)
{
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
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Polls <paramref name="instanceUrl"/> until Xibo returns a 200 from its
/// <c>/about</c> endpoint or <paramref name="timeout"/> elapses.
/// Polls <paramref name="instanceUrl"/> until Xibo is genuinely online by calling
/// the public <c>/about</c> page (no auth required) and confirming the response body
/// contains the word "Xibo". <paramref name="instanceUrl"/> must already include the
/// instance sub-path (e.g. <c>https://ots.ots-signs.com/ots</c>).
/// The JSON <c>/api/about</c> and <c>/api/clock</c> 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".
/// </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");
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}", baseUrl);
_logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", healthUrl);
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
{
try
{
var response = await client.GetAsync($"{baseUrl}/api/about", ct);
var response = await client.GetAsync(healthUrl, ct);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Xibo is ready: {Url}", baseUrl);
return true;
// 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 */ }
@@ -110,7 +165,7 @@ public class XiboApiService
await Task.Delay(TimeSpan.FromSeconds(10), ct);
}
_logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl);
_logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, healthUrl);
return false;
}
@@ -120,31 +175,33 @@ public class XiboApiService
/// <summary>
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
/// obtained from <see cref="LoginAsync"/>).
/// </summary>
public async Task<int> CreateAdminUserAsync(
string instanceUrl,
string bootstrapClientId,
string bootstrapClientSecret,
string accessToken,
string newUsername,
string newPassword,
string email)
string email,
int groupId)
{
var client = _httpClientFactory.CreateClient("XiboApi");
var baseUrl = instanceUrl.TrimEnd('/');
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
SetBearer(client, token);
SetBearer(client, accessToken);
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>("homePageId", "icondashboard.view"),
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>("groupId", groupId.ToString()),
new KeyValuePair<string, string>("password", newPassword),
new KeyValuePair<string, string>("newUserWizard", "0"),
new KeyValuePair<string, string>("hideNavigation", "0"),
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
});
@@ -160,19 +217,19 @@ public class XiboApiService
/// <summary>
/// Changes the password of an existing Xibo user.
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
/// obtained from <see cref="LoginAsync"/>).
/// </summary>
public async Task RotateUserPasswordAsync(
string instanceUrl,
string bootstrapClientId,
string bootstrapClientSecret,
string accessToken,
int userId,
string newPassword)
{
var client = _httpClientFactory.CreateClient("XiboApi");
var baseUrl = instanceUrl.TrimEnd('/');
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
SetBearer(client, token);
SetBearer(client, accessToken);
var form = new FormUrlEncodedContent(new[]
{
@@ -193,18 +250,18 @@ public class XiboApiService
/// <summary>
/// Registers a new client_credentials OAuth2 application in Xibo and returns
/// the generated client_id and client_secret.
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
/// obtained from <see cref="LoginAsync"/>).
/// </summary>
public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync(
string instanceUrl,
string bootstrapClientId,
string bootstrapClientSecret,
string accessToken,
string appName)
{
var client = _httpClientFactory.CreateClient("XiboApi");
var baseUrl = instanceUrl.TrimEnd('/');
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
SetBearer(client, token);
SetBearer(client, accessToken);
var form = new FormUrlEncodedContent(new[]
{
@@ -235,18 +292,18 @@ public class XiboApiService
/// <summary>
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
/// Authenticates using the supplied <paramref name="accessToken"/> (Bearer token
/// obtained from <see cref="LoginAsync"/>).
/// </summary>
public async Task SetThemeAsync(
string instanceUrl,
string bootstrapClientId,
string bootstrapClientSecret,
string accessToken,
string themeFolderName = "otssigns")
{
var client = _httpClientFactory.CreateClient("XiboApi");
var baseUrl = instanceUrl.TrimEnd('/');
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
SetBearer(client, token);
SetBearer(client, accessToken);
// Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns
var form = new FormUrlEncodedContent(new[]
@@ -260,6 +317,152 @@ public class XiboApiService
_logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName);
}
// ─────────────────────────────────────────────────────────────────────────
// User groups
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Creates a new user group and returns its numeric group ID.
/// </summary>
public async Task<int> 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<string, string>("group", groupName),
new KeyValuePair<string, string>("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;
}
/// <summary>
/// Assigns a user to a Xibo user group.
/// </summary>
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<string, string>("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
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Updates an existing Xibo user's username, password, and email.
/// Authenticates using the supplied <paramref name="accessToken"/>.
/// </summary>
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<string, string>("userName", newUsername),
new KeyValuePair<string, string>("email", email),
new KeyValuePair<string, string>("userTypeId", "1"),
new KeyValuePair<string, string>("homePageId", "icondashboard.view"),
new KeyValuePair<string, string>("libraryQuota", "0"),
new KeyValuePair<string, string>("newPassword", newPassword),
new KeyValuePair<string, string>("retypeNewPassword", newPassword),
new KeyValuePair<string, string>("newUserWizard", "0"),
new KeyValuePair<string, string>("hideNavigation", "0"),
new KeyValuePair<string, string>("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);
}
/// <summary>
/// Finds a Xibo user by username and returns their numeric user ID.
/// Authenticates using the supplied <paramref name="accessToken"/>.
/// </summary>
public async Task<int> 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.");
}
/// <summary>
/// Deletes a Xibo user by their numeric user ID.
/// Authenticates using the supplied <paramref name="accessToken"/>.
/// </summary>
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
// ─────────────────────────────────────────────────────────────────────────
@@ -302,6 +505,8 @@ public class XiboApiService
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)