|
| 1 | +using System.Security.Cryptography; |
| 2 | +using System.Text.Json; |
| 3 | +using System.Text.Json.Nodes; |
| 4 | +using Microsoft.Extensions.Configuration; |
| 5 | +using Microsoft.Extensions.Configuration.EnvironmentVariables; |
| 6 | + |
| 7 | +namespace Taskdeck.Api.FirstRun; |
| 8 | + |
| 9 | +/// <summary> |
| 10 | +/// Runs synchronously before the DI container is built. |
| 11 | +/// Ensures that auto-generated configuration values (JWT secret, DB path) |
| 12 | +/// are written to <c>appsettings.local.json</c> so they are available to |
| 13 | +/// all subsequent configuration consumers. |
| 14 | +/// </summary> |
| 15 | +public static class FirstRunBootstrapper |
| 16 | +{ |
| 17 | + // Placeholder values that indicate "not configured" |
| 18 | + private static readonly HashSet<string> PlaceholderSecrets = |
| 19 | + new(StringComparer.OrdinalIgnoreCase) |
| 20 | + { |
| 21 | + string.Empty, |
| 22 | + "TaskdeckDevelopmentOnlySecretKeyChangeMe123!" |
| 23 | + }; |
| 24 | + |
| 25 | + /// <summary> |
| 26 | + /// Registers <c>appsettings.local.json</c> as an optional configuration |
| 27 | + /// source so that previously generated secrets are picked up. |
| 28 | + /// Call this before building <see cref="WebApplication"/>. |
| 29 | + /// </summary> |
| 30 | + /// <remarks> |
| 31 | + /// The source is inserted <em>before</em> any |
| 32 | + /// <see cref="EnvironmentVariablesConfigurationSource"/> entries so that |
| 33 | + /// environment variables always win over the auto-generated file. This |
| 34 | + /// preserves 12-factor / container deployment patterns where operators |
| 35 | + /// supply <c>Jwt__SecretKey</c> (or similar) via environment variables |
| 36 | + /// and must not be silently overridden by a previously written file. |
| 37 | + /// </remarks> |
| 38 | + public static WebApplicationBuilder AddLocalConfigFile(this WebApplicationBuilder builder) |
| 39 | + { |
| 40 | + var sources = builder.Configuration.Sources; |
| 41 | + |
| 42 | + // Find the first EnvironmentVariablesConfigurationSource so we can |
| 43 | + // insert the file source before it, giving env vars higher priority. |
| 44 | + var envIndex = -1; |
| 45 | + for (var i = 0; i < sources.Count; i++) |
| 46 | + { |
| 47 | + if (sources[i] is EnvironmentVariablesConfigurationSource) |
| 48 | + { |
| 49 | + envIndex = i; |
| 50 | + break; |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + var fileSource = new Microsoft.Extensions.Configuration.Json.JsonConfigurationSource |
| 55 | + { |
| 56 | + Path = LocalConfigPath, |
| 57 | + Optional = true, |
| 58 | + ReloadOnChange = false |
| 59 | + }; |
| 60 | + // Resolve the file provider so the source can locate the file. |
| 61 | + fileSource.ResolveFileProvider(); |
| 62 | + |
| 63 | + if (envIndex >= 0) |
| 64 | + { |
| 65 | + sources.Insert(envIndex, fileSource); |
| 66 | + } |
| 67 | + else |
| 68 | + { |
| 69 | + // No env-var source found (unusual); append at end. |
| 70 | + sources.Add(fileSource); |
| 71 | + } |
| 72 | + |
| 73 | + return builder; |
| 74 | + } |
| 75 | + |
| 76 | + /// <summary> |
| 77 | + /// Runs the first-run checks. Must be called after |
| 78 | + /// <see cref="AddLocalConfigFile"/> and after the standard |
| 79 | + /// <c>appsettings.json</c> / <c>appsettings.{env}.json</c> files have been |
| 80 | + /// loaded (i.e. after the builder is constructed). |
| 81 | + /// </summary> |
| 82 | + /// <remarks> |
| 83 | + /// Checks are skipped in Development and in CI/headless environments. |
| 84 | + /// They are intended for the packaged self-hosted production scenario. |
| 85 | + /// </remarks> |
| 86 | + public static WebApplicationBuilder RunFirstRunChecks( |
| 87 | + this WebApplicationBuilder builder, |
| 88 | + ILogger logger) |
| 89 | + { |
| 90 | + // First-run checks are for the self-hosted packaged distribution. |
| 91 | + // In Development the developer supplies their own config values. |
| 92 | + if (builder.Environment.IsDevelopment()) |
| 93 | + { |
| 94 | + return builder; |
| 95 | + } |
| 96 | + |
| 97 | + // Also skip in CI / automated environments. |
| 98 | + if (IsHeadlessEnvironment()) |
| 99 | + { |
| 100 | + return builder; |
| 101 | + } |
| 102 | + |
| 103 | + EnsureJwtSecret(builder.Configuration, logger); |
| 104 | + EnsureDbPath(builder.Configuration, logger); |
| 105 | + return builder; |
| 106 | + } |
| 107 | + |
| 108 | + // ------------------------------------------------------------------------- |
| 109 | + |
| 110 | + internal static string LocalConfigPath |
| 111 | + { |
| 112 | + get |
| 113 | + { |
| 114 | + var dir = AppContext.BaseDirectory; |
| 115 | + return Path.Combine(dir, "appsettings.local.json"); |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + internal static bool IsPlaceholder(string value) |
| 120 | + => string.IsNullOrWhiteSpace(value) || PlaceholderSecrets.Contains(value.Trim()); |
| 121 | + |
| 122 | + internal static string GenerateSecret() |
| 123 | + => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); |
| 124 | + |
| 125 | + private static void EnsureJwtSecret(IConfiguration configuration, ILogger logger) |
| 126 | + { |
| 127 | + var configured = configuration["Jwt:SecretKey"] ?? string.Empty; |
| 128 | + |
| 129 | + if (!IsPlaceholder(configured)) |
| 130 | + { |
| 131 | + return; |
| 132 | + } |
| 133 | + |
| 134 | + var generated = GenerateSecret(); |
| 135 | + PersistValue("Jwt", "SecretKey", generated); |
| 136 | + // Reload so subsequent configuration reads get the new value. |
| 137 | + if (configuration is IConfigurationRoot root) |
| 138 | + { |
| 139 | + root.Reload(); |
| 140 | + } |
| 141 | + |
| 142 | + logger.LogInformation( |
| 143 | + "First-run: JWT secret was not configured. A random secret has been " + |
| 144 | + "generated and saved to {ConfigFile}.", LocalConfigPath); |
| 145 | + } |
| 146 | + |
| 147 | + private static void EnsureDbPath(IConfiguration configuration, ILogger logger) |
| 148 | + { |
| 149 | + var resolveAppData = configuration.GetValue<bool?>("FirstRun:ResolveAppDataDbPath") ?? true; |
| 150 | + if (!resolveAppData) |
| 151 | + { |
| 152 | + return; |
| 153 | + } |
| 154 | + |
| 155 | + var connectionString = configuration.GetConnectionString("DefaultConnection") |
| 156 | + ?? string.Empty; |
| 157 | + var dataSource = ExtractDataSource(connectionString); |
| 158 | + |
| 159 | + // Already an absolute path — nothing to do. |
| 160 | + if (!string.IsNullOrWhiteSpace(dataSource) && Path.IsPathRooted(dataSource)) |
| 161 | + { |
| 162 | + return; |
| 163 | + } |
| 164 | + |
| 165 | + var appDataDir = GetAppDataPath(); |
| 166 | + Directory.CreateDirectory(appDataDir); |
| 167 | + |
| 168 | + var dbFile = string.IsNullOrWhiteSpace(dataSource) |
| 169 | + ? "taskdeck.db" |
| 170 | + : Path.GetFileName(dataSource); |
| 171 | + |
| 172 | + var resolvedPath = Path.Combine(appDataDir, dbFile); |
| 173 | + var resolvedConnectionString = $"Data Source={resolvedPath}"; |
| 174 | + |
| 175 | + // Write into the local config file so the value is picked up by |
| 176 | + // AddInfrastructure later in the startup pipeline. |
| 177 | + PersistValue("ConnectionStrings", "DefaultConnection", resolvedConnectionString); |
| 178 | + |
| 179 | + if (configuration is IConfigurationRoot root) |
| 180 | + { |
| 181 | + root.Reload(); |
| 182 | + } |
| 183 | + |
| 184 | + logger.LogInformation( |
| 185 | + "First-run: SQLite DB path resolved to AppData location: {DbPath}", resolvedPath); |
| 186 | + } |
| 187 | + |
| 188 | + internal static bool IsHeadlessEnvironment() |
| 189 | + { |
| 190 | + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")) |
| 191 | + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TF_BUILD")) |
| 192 | + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("GITHUB_ACTIONS")) |
| 193 | + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TASKDECK_HEADLESS")); |
| 194 | + } |
| 195 | + |
| 196 | + internal static string GetAppDataPath() |
| 197 | + { |
| 198 | + var localAppData = Environment.GetFolderPath( |
| 199 | + Environment.SpecialFolder.LocalApplicationData, |
| 200 | + Environment.SpecialFolderOption.Create); |
| 201 | + return Path.Combine(localAppData, "Taskdeck"); |
| 202 | + } |
| 203 | + |
| 204 | + private static string ExtractDataSource(string connectionString) |
| 205 | + { |
| 206 | + if (string.IsNullOrWhiteSpace(connectionString)) |
| 207 | + return string.Empty; |
| 208 | + |
| 209 | + try |
| 210 | + { |
| 211 | + var builder = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString); |
| 212 | + return builder.DataSource; |
| 213 | + } |
| 214 | + catch (ArgumentException) |
| 215 | + { |
| 216 | + return string.Empty; |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + private static void PersistValue(string section, string key, string value) |
| 221 | + { |
| 222 | + var path = LocalConfigPath; |
| 223 | + |
| 224 | + JsonObject root; |
| 225 | + if (File.Exists(path)) |
| 226 | + { |
| 227 | + try |
| 228 | + { |
| 229 | + var existing = File.ReadAllText(path); |
| 230 | + root = JsonNode.Parse(existing)?.AsObject() ?? new JsonObject(); |
| 231 | + } |
| 232 | + catch (Exception ex) |
| 233 | + { |
| 234 | + // Logger is not available at this pre-DI stage; warn to stderr and start fresh |
| 235 | + // rather than silently discarding the corrupt file. |
| 236 | + Console.Error.WriteLine( |
| 237 | + $"[FirstRun] WARNING: {path} contains invalid JSON and will be overwritten. " + |
| 238 | + $"Details: {ex.Message}"); |
| 239 | + root = new JsonObject(); |
| 240 | + } |
| 241 | + } |
| 242 | + else |
| 243 | + { |
| 244 | + root = new JsonObject(); |
| 245 | + } |
| 246 | + |
| 247 | + if (root[section] is not JsonObject sectionNode) |
| 248 | + { |
| 249 | + sectionNode = new JsonObject(); |
| 250 | + root[section] = sectionNode; |
| 251 | + } |
| 252 | + |
| 253 | + sectionNode[key] = value; |
| 254 | + |
| 255 | + var options = new JsonSerializerOptions { WriteIndented = true }; |
| 256 | + File.WriteAllText(path, root.ToJsonString(options)); |
| 257 | + } |
| 258 | +} |
0 commit comments