예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase) 위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는 인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음. 인증/세션 - 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지 - ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로 로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example) - Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel 기능 복원 (실데이터) - Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력) - Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계 - License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용), 개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록 - Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움 - 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반) - 사용자 상세 티어별 기능 배지(pro_plus 조건부) .NET - SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
487 lines
19 KiB
C#
487 lines
19 KiB
C#
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<AppDbContext>(options =>
|
|
options.UseSqlite($"Data Source={dbPath}"));
|
|
|
|
// Services Registration
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
builder.Services.AddScoped<ILlmProxyService, LlmProxyService>();
|
|
builder.Services.AddScoped<ISttProxyService, SttProxyService>();
|
|
builder.Services.AddScoped<IAdminOperationService, AdminOperationService>();
|
|
|
|
// 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<AppDbContext>();
|
|
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<User>().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 { }
|