using Microsoft.Extensions.Logging; namespace OTSSignsOrchestrator.Core.Services; /// /// 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). /// public class SettingsService { private readonly IBitwardenSecretService _bws; private readonly ILogger _logger; /// Prefix applied to all config secret keys in Bitwarden. private const string KeyPrefix = "ots-config/"; // ── Category constants ───────────────────────────────────────────────── public const string CatGit = "Git"; public const string CatMySql = "MySql"; public const string CatSmtp = "Smtp"; public const string CatPangolin = "Pangolin"; public const string CatNfs = "Nfs"; public const string CatDefaults = "Defaults"; // ── Key constants ────────────────────────────────────────────────────── // Git public const string GitRepoUrl = "Git.RepoUrl"; public const string GitRepoPat = "Git.RepoPat"; // MySQL Admin public const string MySqlHost = "MySql.Host"; public const string MySqlPort = "MySql.Port"; public const string MySqlAdminUser = "MySql.AdminUser"; public const string MySqlAdminPassword = "MySql.AdminPassword"; // SMTP public const string SmtpServer = "Smtp.Server"; public const string SmtpPort = "Smtp.Port"; public const string SmtpUsername = "Smtp.Username"; public const string SmtpPassword = "Smtp.Password"; public const string SmtpUseTls = "Smtp.UseTls"; public const string SmtpUseStartTls = "Smtp.UseStartTls"; public const string SmtpRewriteDomain = "Smtp.RewriteDomain"; public const string SmtpHostname = "Smtp.Hostname"; public const string SmtpFromLineOverride = "Smtp.FromLineOverride"; // Pangolin public const string PangolinEndpoint = "Pangolin.Endpoint"; // NFS public const string NfsServer = "Nfs.Server"; public const string NfsExport = "Nfs.Export"; public const string NfsExportFolder = "Nfs.ExportFolder"; public const string NfsOptions = "Nfs.Options"; // Instance Defaults public const string DefaultCmsImage = "Defaults.CmsImage"; public const string DefaultNewtImage = "Defaults.NewtImage"; public const string DefaultMemcachedImage = "Defaults.MemcachedImage"; public const string DefaultQuickChartImage = "Defaults.QuickChartImage"; public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate"; public const string DefaultThemeHostPath = "Defaults.ThemeHostPath"; public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate"; public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate"; public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize"; public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize"; public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime"; // Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access) public const string CatXibo = "Xibo"; public const string XiboBootstrapClientId = "Xibo.BootstrapClientId"; public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret"; // Instance-specific (keyed by abbreviation) /// /// Builds a per-instance settings key for the MySQL password. /// public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword"; /// Bitwarden secret ID for the instance's OTS admin password. public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId"; /// Bitwarden secret ID for the instance's Xibo OAuth2 client secret. public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId"; /// Xibo OAuth2 client_id generated for this instance's OTS application. 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? s_cache; public SettingsService( IBitwardenSecretService bws, ILogger logger) { _bws = bws; _logger = logger; } /// Get a single setting value from Bitwarden. public async Task GetAsync(string key) { 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; } /// Get a setting with a fallback default. public async Task GetAsync(string key, string defaultValue) => await GetAsync(key) ?? defaultValue; /// Set a single setting in Bitwarden (creates or updates). public async Task SetAsync(string key, string? value, string category, bool isSensitive = false) { 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; 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; } } /// Save multiple settings in a batch. public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings) { var count = 0; var errors = new List(); foreach (var (key, value, category, isSensitive) in settings) { try { await SetAsync(key, value, category, isSensitive); count++; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to save setting {Key}", key); errors.Add(key); } } _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)}"); } /// Get all settings in a category (by examining cached keys). public async Task> GetCategoryAsync(string category) { var cache = await EnsureCacheAsync(); var prefix = KeyPrefix + category + "."; var result = new Dictionary(); 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; } /// /// Invalidates the in-memory cache so next access re-fetches from Bitwarden. /// public void InvalidateCache() => s_cache = null; /// /// Pre-loads the settings cache from Bitwarden. /// Call once at startup so settings are available immediately. /// public async Task PreloadCacheAsync() { InvalidateCache(); await EnsureCacheAsync(); } // ───────────────────────────────────────────────────────────────────────── // Cache management // ───────────────────────────────────────────────────────────────────────── private async Task> EnsureCacheAsync() { if (s_cache is not null) return s_cache; var cache = new Dictionary(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 { // 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.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured"); } s_cache = cache; return s_cache; } }