d3ro-voice/apps/api-server/Program.cs
Yun Chan 5a34f66981 feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리
예전 배포본(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
2026-08-23 23:38:08 +09:00

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 { }