d3ro-voice/apps/api-server/Program.cs
Yun Chan cd9d199dbf chore: remove duplicate copies of the site, download center and installers (WS-C)
The landing page, download pages, invite page, assetlinks and installers
existed in two or three places; only site/ and the Forgejo feed are served.

- apps/api-server/wwwroot: delete the stale site build, download/invite
  pages, .well-known copy, legacy static admin and 1.0.0 binaries. The API
  no longer serves static files (UseStaticFiles/fallbacks and the apk/zip
  blocker removed); the Next admin is the only admin UI.
- Delete 19 tracked installers/packages (~568 MiB) under site/public/releases,
  apps/web/public/releases and wwwroot/releases; .gitignore blocks them.
- apps/web: delete the download/releases pages, desktop-release.ts, the
  download.html and assetlinks copies, and the accept-invite page (invites
  are only issued to the site's /accept-invite/). e2e specs call the /app
  base path and check the /download redirect instead.
- scripts: delete the retired release/NAS site scripts, drop the web target
  from sync-version, and check assetlinks in site/public only.
- Delete the unused Dockerfile.admin (apps/admin/Dockerfile is used).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-5 and W3-6.
2026-09-26 15:48:52 +09:00

429 lines
16 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;
var releaseVersion = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(3)
?? "unknown";
// 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 = releaseVersion,
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");
// The API serves no static pages. Landing, download, legal, invite and
// assetlinks pages live only in site/ (Cloudflare Pages); the admin UI is
// apps/admin. Installers are published only through the Forgejo update feed.
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 = releaseVersion,
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 = releaseVersion,
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
database = File.Exists(dbPath) ? "Connected" : "Initializing",
timestamp = DateTime.UtcNow
}));
app.MapControllers();
app.Run();
public partial class Program { }