using System.Text; using D3ROVoice.Api.Data; using D3ROVoice.Api.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); var serverStartTime = DateTime.UtcNow; // Add Services to Container builder.Services.AddControllers(); builder.Services.AddHttpClient(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); }); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "D3RO Voice Cloud API & BackOffice", Version = "v1", Description = "D3RO Voice Self-Hosted Cloud Backend for NAS & Docker" }); }); // Database Connection (SQLite with configurable NAS volume directory) var dataDir = builder.Configuration["DATA_DIR"] ?? (Directory.Exists("/app/data") ? "/app/data" : Path.Combine(AppContext.BaseDirectory, "data")); var dbPath = builder.Configuration["DB_PATH"] ?? builder.Configuration["DATABASE_PATH"] ?? Path.Combine(dataDir, "d3ro_api.db"); var dbDirectory = Path.GetDirectoryName(dbPath); if (!string.IsNullOrEmpty(dbDirectory) && !Directory.Exists(dbDirectory)) { Directory.CreateDirectory(dbDirectory); } builder.Services.AddDbContext(options => options.UseSqlite($"Data Source={dbPath}")); // Services Registration builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // JWT Authentication Configuration var secretKey = builder.Configuration["JWT_SECRET"]; if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32) { throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes."); } var jwtIssuer = builder.Configuration["JWT_ISSUER"]; if (string.IsNullOrWhiteSpace(jwtIssuer)) { throw new InvalidOperationException("JWT_ISSUER is required."); } var jwtAudience = builder.Configuration["JWT_AUDIENCE"]; if (string.IsNullOrWhiteSpace(jwtAudience)) { throw new InvalidOperationException("JWT_AUDIENCE is required."); } var keyBytes = Encoding.UTF8.GetBytes(secretKey); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(keyBytes), ValidateIssuer = true, ValidIssuer = jwtIssuer, ValidateAudience = true, ValidAudience = jwtAudience, RequireExpirationTime = true, ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; }); static string NormalizeAdminRole(string value) => value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal) .Trim() .ToLowerInvariant(); builder.Services.AddAuthorization(options => { options.AddPolicy("ManagerOrAbove", policy => policy.RequireAuthenticatedUser().RequireAssertion(context => context.User.Claims .Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role") .Select(claim => NormalizeAdminRole(claim.Value)) .Any(role => role is "manager" or "admin" or "superadmin"))); options.AddPolicy("AdminOrAbove", policy => policy.RequireAuthenticatedUser().RequireAssertion(context => context.User.Claims .Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role") .Select(claim => NormalizeAdminRole(claim.Value)) .Any(role => role is "admin" or "superadmin"))); options.AddPolicy("SuperAdminOnly", policy => policy.RequireAuthenticatedUser().RequireAssertion(context => context.User.Claims .Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role") .Select(claim => NormalizeAdminRole(claim.Value)) .Any(role => role is "superadmin"))); }); var corsOriginsRaw = builder.Configuration["Cors:AllowedOrigins"] ?? builder.Configuration["CORS_ALLOWED_ORIGINS"]; if (string.IsNullOrWhiteSpace(corsOriginsRaw)) { if (!builder.Environment.IsDevelopment()) { throw new InvalidOperationException("CORS_ALLOWED_ORIGINS is required outside Development."); } corsOriginsRaw = "http://localhost:3000,http://localhost:3001,http://localhost:5173"; } var corsOrigins = corsOriginsRaw .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(origin => origin.TrimEnd('/')) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); if (corsOrigins.Length == 0 || corsOrigins.Any(origin => !Uri.TryCreate(origin, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) || !string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || uri.AbsolutePath != "/")) { throw new InvalidOperationException("CORS_ALLOWED_ORIGINS must be a comma-separated list of HTTP(S) origins without paths or wildcards."); } var allowedHosts = builder.Configuration["ALLOWED_HOSTS"]; if (string.IsNullOrWhiteSpace(allowedHosts)) { if (!builder.Environment.IsDevelopment()) { throw new InvalidOperationException("ALLOWED_HOSTS is required outside Development."); } allowedHosts = "localhost;127.0.0.1"; } if (allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Any(host => host == "*" || host.Contains('/') || host.Contains('\\'))) { throw new InvalidOperationException("ALLOWED_HOSTS must contain explicit semicolon-separated host names without wildcards or paths."); } builder.Configuration["AllowedHosts"] = allowedHosts; builder.Services.AddCors(options => { options.AddPolicy("ConfiguredOrigins", policy => { policy.WithOrigins(corsOrigins) .AllowAnyMethod() .AllowAnyHeader() .SetPreflightMaxAge(TimeSpan.FromHours(1)); }); }); var app = builder.Build(); // Ensure Database is Created & Initialized with default data using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); db.Database.ExecuteSqlRaw(""" CREATE TABLE IF NOT EXISTS "AdminOperationRequests" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_AdminOperationRequests" PRIMARY KEY AUTOINCREMENT, "ActorEmail" TEXT NOT NULL, "IdempotencyKey" TEXT NOT NULL, "Operation" TEXT NOT NULL, "RequestHash" TEXT NOT NULL, "ResponseJson" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS "IX_AdminOperationRequests_ActorEmail_IdempotencyKey" ON "AdminOperationRequests" ("ActorEmail", "IdempotencyKey"); CREATE TABLE IF NOT EXISTS "AdminAuditEntries" ( "Id" INTEGER NOT NULL CONSTRAINT "PK_AdminAuditEntries" PRIMARY KEY AUTOINCREMENT, "ActorEmail" TEXT NOT NULL, "Action" TEXT NOT NULL, "TargetType" TEXT NOT NULL, "TargetId" TEXT NOT NULL, "BeforeJson" TEXT NULL, "AfterJson" TEXT NULL, "Memo" TEXT NOT NULL, "IdempotencyKey" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt" ON "AdminAuditEntries" ("CreatedAt"); """); // The former repository-wide SHA-256 password scheme and seeded admin // credentials are compromised by design. Disable those rows so they can // never authenticate; an operator provisions the administrator either via // the one-time bootstrap token or the ADMIN_EMAIL/ADMIN_PASSWORD env pair // below (auto-created only when no active administrator exists). var legacyPasswordUsers = db.Users .Where(user => !user.PasswordHash.StartsWith("AQAAAA")) .ToList(); if (legacyPasswordUsers.Count > 0) { foreach (var legacyUser in legacyPasswordUsers) { legacyUser.IsActive = false; legacyUser.Role = "LegacyDisabled"; } db.SaveChanges(); } // .env 기반 관리자 프로비저닝: 활성 관리자가 없을 때만 ADMIN_EMAIL/ADMIN_PASSWORD // 로 SuperAdmin을 자동 생성한다(멱등 — 이미 있으면 건드리지 않는다). var envAdminEmail = builder.Configuration["ADMIN_EMAIL"]?.Trim().ToLowerInvariant() ?? string.Empty; var envAdminPassword = builder.Configuration["ADMIN_PASSWORD"] ?? string.Empty; if ( envAdminEmail.Length >= 3 && envAdminEmail.Contains('@') && envAdminPassword.Length >= 8 && !db.Users.Any(u => u.IsActive) ) { var envAdmin = new User { Email = envAdminEmail, Role = "SuperAdmin", CreatedAt = DateTime.UtcNow, IsActive = true, }; envAdmin.PasswordHash = new PasswordHasher().HashPassword(envAdmin, envAdminPassword); db.Users.Add(envAdmin); db.SaveChanges(); Console.WriteLine($"Provisioned SuperAdmin from ADMIN_EMAIL env: {envAdminEmail}"); } // Default Model Endpoints if empty if (!db.ModelEndpoints.Any()) { db.ModelEndpoints.AddRange( new ServiceModelEndpoint { ModelId = "d3ro-gpt4o-mini", ModelName = "D3RO Standard Model (GPT-4o Mini)", Provider = "OpenAI", EndpointUrl = "https://api.openai.com/v1/chat/completions", CostPer1kPromptTokens = 0.00015m, CostPer1kCompletionTokens = 0.00060m, IsActive = true }, new ServiceModelEndpoint { ModelId = "d3ro-claude-35-sonnet", ModelName = "D3RO Pro Model (Claude 3.5 Sonnet)", Provider = "Anthropic", EndpointUrl = "https://api.anthropic.com/v1/messages", CostPer1kPromptTokens = 0.00300m, CostPer1kCompletionTokens = 0.01500m, IsActive = true } ); db.SaveChanges(); } // Default STT Provider Endpoints if empty if (!db.SttProviderEndpoints.Any()) { db.SttProviderEndpoints.AddRange( new SttProviderEndpoint { Name = "Groq Whisper LPU Turbo (Ultra Fast)", ProviderType = "groq", EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions", ApiKey = builder.Configuration["GROQ_API_KEY"] ?? "", ModelId = "whisper-large-v3-turbo", Method = "multipart", Language = "ko", CostPerMinute = 0.000500m, CostPerSecond = 0.000008m, IsDefault = true, IsActive = true, FallbackPriority = 1, CreatedAt = DateTime.UtcNow }, new SttProviderEndpoint { Name = "OpenAI Whisper Official", ProviderType = "openai", EndpointUrl = "https://api.openai.com/v1/audio/transcriptions", ApiKey = builder.Configuration["OPENAI_API_KEY"] ?? "", ModelId = "whisper-1", Method = "multipart", Language = "ko", CostPerMinute = 0.006000m, CostPerSecond = 0.000100m, IsDefault = false, IsActive = true, FallbackPriority = 2, CreatedAt = DateTime.UtcNow }, new SttProviderEndpoint { Name = "Deepgram Nova-3 Industry Standard", ProviderType = "deepgram", EndpointUrl = "https://api.deepgram.com/v1/listen", ApiKey = builder.Configuration["DEEPGRAM_API_KEY"] ?? "", ModelId = "nova-3", Method = "binary-stream", Language = "ko", CostPerMinute = 0.004300m, CostPerSecond = 0.000072m, IsDefault = false, IsActive = true, FallbackPriority = 3, CreatedAt = DateTime.UtcNow }, new SttProviderEndpoint { Name = "Google Gemini 2.0 Flash / Cloud STT", ProviderType = "google", EndpointUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent", ApiKey = builder.Configuration["GEMINI_API_KEY"] ?? builder.Configuration["GOOGLE_API_KEY"] ?? "", ModelId = "gemini-2.0-flash", Method = "json-base64", Language = "ko", CostPerMinute = 0.001000m, CostPerSecond = 0.000017m, IsDefault = false, IsActive = true, FallbackPriority = 4, CreatedAt = DateTime.UtcNow }, new SttProviderEndpoint { Name = "Local Sidecar / Self-Hosted Whisper", ProviderType = "local-sidecar", EndpointUrl = "http://localhost:8971/stt/transcribe", ApiKey = "", ModelId = "whisper-large-v3-turbo", Method = "multipart", Language = "ko", CostPerMinute = 0.000000m, CostPerSecond = 0.000000m, IsDefault = false, IsActive = true, FallbackPriority = 5, CreatedAt = DateTime.UtcNow } ); db.SaveChanges(); } // Administrative accounts are never seeded with repository credentials. // Provisioning is performed through the authenticated one-time bootstrap flow. } if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseCors("ConfiguredOrigins"); app.Use(async (context, next) => { var isInvitePage = context.Request.Path.StartsWithSegments("/accept-invite") || context.Request.Path.Equals("/accept-invite.html"); if (isInvitePage) { context.Response.OnStarting(() => { // The invite token lives in the query string. Do not cache the page and // prevent CDN HTML transforms (including analytics script injection). context.Response.Headers["Cache-Control"] = "no-store, no-transform"; context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " + "form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " + "object-src 'none'; script-src 'self'; style-src 'self'"; context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()"; context.Response.Headers["Referrer-Policy"] = "no-referrer"; context.Response.Headers["X-Content-Type-Options"] = "nosniff"; context.Response.Headers["X-Frame-Options"] = "DENY"; return Task.CompletedTask; }); } if (context.Request.Path.Equals("/accept-invite")) { context.Response.StatusCode = StatusCodes.Status308PermanentRedirect; context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}"; return; } await next(); }); app.UseDefaultFiles(); // Historical mobile binaries remain in the checkout for forensics only. They // are not official releases and must never be reachable through StaticFiles. app.Use(async (context, next) => { var requestPath = context.Request.Path.Value ?? string.Empty; var fileName = Path.GetFileName(requestPath); var legacyMarketingAsset = requestPath.Equals("/assets/index-D7M5UQvT.js", StringComparison.OrdinalIgnoreCase) || requestPath.Equals("/assets/index-JlYFxlAJ.js", StringComparison.OrdinalIgnoreCase); var mobileReleasePath = requestPath.StartsWith("/releases/", StringComparison.OrdinalIgnoreCase) && (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".aab", StringComparison.OrdinalIgnoreCase) || (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) && (fileName.Contains("android", StringComparison.OrdinalIgnoreCase) || fileName.Contains("signed", StringComparison.OrdinalIgnoreCase)))); if (mobileReleasePath || legacyMarketingAsset) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } await next(); }); app.UseStaticFiles(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // Health Check Endpoints for Docker & NAS Container Monitoring app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "D3RO Voice Cloud API", version = "1.0.0", uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds, database = File.Exists(dbPath) ? "Connected" : "Initializing", timestamp = DateTime.UtcNow })); app.MapGet("/api/health", () => Results.Ok(new { status = "Healthy", service = "D3RO Voice Cloud API", version = "1.0.0", uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds, database = File.Exists(dbPath) ? "Connected" : "Initializing", timestamp = DateTime.UtcNow })); app.MapControllers(); app.MapFallbackToFile("/accept-invite", "accept-invite.html"); // Fallback to Admin BackOffice UI index.html app.MapFallbackToFile("/admin/{*path}", "admin/index.html"); app.Run(); public partial class Program { }