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
This commit is contained in:
parent
a9c9a1ca6e
commit
5a34f66981
66 changed files with 4471 additions and 3501 deletions
|
|
@ -1,6 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
|
|
@ -13,16 +15,19 @@ namespace D3ROVoice.Api.Controllers;
|
|||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = "ManagerOrAbove")]
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ISttProxyService _sttService;
|
||||
private readonly IAdminOperationService _adminOperations;
|
||||
private static readonly DateTime _serverStartTime = DateTime.UtcNow;
|
||||
|
||||
public AdminController(AppDbContext db, ISttProxyService sttService)
|
||||
public AdminController(AppDbContext db, ISttProxyService sttService, IAdminOperationService adminOperations)
|
||||
{
|
||||
_db = db;
|
||||
_sttService = sttService;
|
||||
_adminOperations = adminOperations;
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
|
|
@ -75,73 +80,121 @@ public class AdminController : ControllerBase
|
|||
public async Task<IActionResult> GetEndpoints()
|
||||
{
|
||||
var endpoints = await _db.ModelEndpoints
|
||||
.AsNoTracking()
|
||||
.OrderBy(m => m.Id)
|
||||
.Select(endpoint => new
|
||||
{
|
||||
endpoint.Id,
|
||||
endpoint.ModelId,
|
||||
endpoint.ModelName,
|
||||
endpoint.Provider,
|
||||
endpoint.EndpointUrl,
|
||||
ApiKey = endpoint.ApiKey == "" ? "" : "••••••••",
|
||||
endpoint.CostPer1kPromptTokens,
|
||||
endpoint.CostPer1kCompletionTokens,
|
||||
endpoint.IsActive,
|
||||
endpoint.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(endpoints);
|
||||
}
|
||||
|
||||
[HttpPost("endpoints")]
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> CreateEndpoint([FromBody] CreateModelEndpointDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName))
|
||||
if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName)
|
||||
|| string.IsNullOrWhiteSpace(dto.Provider) || !IsValidEndpointUrl(dto.EndpointUrl)
|
||||
|| dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0)
|
||||
{
|
||||
return BadRequest(new { message = "ModelId와 ModelName은 필수 항목입니다." });
|
||||
return BadRequest(new { message = "모델 식별자, 공급자, 유효한 HTTP(S) URL과 0 이상의 비용이 필요합니다." });
|
||||
}
|
||||
|
||||
var existing = await _db.ModelEndpoints.FirstOrDefaultAsync(m => m.ModelId == dto.ModelId);
|
||||
if (existing != null)
|
||||
{
|
||||
return Conflict(new { message = "이미 존재하는 ModelId입니다." });
|
||||
}
|
||||
|
||||
var endpoint = new ServiceModelEndpoint
|
||||
{
|
||||
ModelId = dto.ModelId.Trim(),
|
||||
ModelName = dto.ModelName.Trim(),
|
||||
Provider = dto.Provider.Trim(),
|
||||
EndpointUrl = dto.EndpointUrl.Trim(),
|
||||
ApiKey = dto.ApiKey ?? "",
|
||||
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
|
||||
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.ModelEndpoints.Add(endpoint);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(endpoint);
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"model_endpoint.create",
|
||||
dto,
|
||||
"model_endpoint",
|
||||
_ => dto.ModelId.Trim(),
|
||||
dto.Memo,
|
||||
() => Task.FromResult<object?>(null),
|
||||
async () =>
|
||||
{
|
||||
if (await _db.ModelEndpoints.AnyAsync(model => model.ModelId == dto.ModelId.Trim()))
|
||||
throw new AdminOperationException("model_id_already_exists");
|
||||
var endpoint = new ServiceModelEndpoint
|
||||
{
|
||||
ModelId = dto.ModelId.Trim(),
|
||||
ModelName = dto.ModelName.Trim(),
|
||||
Provider = dto.Provider.Trim(),
|
||||
EndpointUrl = dto.EndpointUrl.Trim(),
|
||||
ApiKey = dto.ApiKey?.Trim() ?? "",
|
||||
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
|
||||
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.ModelEndpoints.Add(endpoint);
|
||||
await _db.SaveChangesAsync();
|
||||
return ToModelEndpointDto(endpoint);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("endpoints/{id}")]
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto)
|
||||
{
|
||||
var endpoint = await _db.ModelEndpoints.FindAsync(id);
|
||||
if (endpoint == null) return NotFound();
|
||||
if (id <= 0 || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider)
|
||||
|| !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0)
|
||||
return BadRequest(new { message = "유효한 모델 엔드포인트 값이 필요합니다." });
|
||||
|
||||
endpoint.ModelName = dto.ModelName.Trim();
|
||||
endpoint.Provider = dto.Provider.Trim();
|
||||
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
|
||||
endpoint.ApiKey = dto.ApiKey ?? "";
|
||||
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
|
||||
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
|
||||
endpoint.IsActive = dto.IsActive;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(endpoint);
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"model_endpoint.update",
|
||||
new { id, dto },
|
||||
"model_endpoint",
|
||||
_ => id.ToString(),
|
||||
dto.Memo,
|
||||
async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||
.Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive })
|
||||
.SingleOrDefaultAsync(),
|
||||
async () =>
|
||||
{
|
||||
var endpoint = await _db.ModelEndpoints.FindAsync(id)
|
||||
?? throw new AdminOperationException("model_endpoint_not_found");
|
||||
endpoint.ModelName = dto.ModelName.Trim();
|
||||
endpoint.Provider = dto.Provider.Trim();
|
||||
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
|
||||
if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
|
||||
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
|
||||
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
|
||||
endpoint.IsActive = dto.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
return ToModelEndpointDto(endpoint);
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("endpoints/{id}")]
|
||||
public async Task<IActionResult> DeleteEndpoint(int id)
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> DeleteEndpoint(int id, [FromBody] AdminActionDto dto)
|
||||
{
|
||||
var endpoint = await _db.ModelEndpoints.FindAsync(id);
|
||||
if (endpoint == null) return NotFound();
|
||||
|
||||
_db.ModelEndpoints.Remove(endpoint);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "삭제되었습니다." });
|
||||
if (id <= 0) return BadRequest();
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"model_endpoint.delete",
|
||||
new { id, dto.Memo },
|
||||
"model_endpoint",
|
||||
_ => id.ToString(),
|
||||
dto.Memo,
|
||||
async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||
.Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive })
|
||||
.SingleOrDefaultAsync(),
|
||||
async () =>
|
||||
{
|
||||
var endpoint = await _db.ModelEndpoints.FindAsync(id)
|
||||
?? throw new AdminOperationException("model_endpoint_not_found");
|
||||
_db.ModelEndpoints.Remove(endpoint);
|
||||
await _db.SaveChangesAsync();
|
||||
return new { message = "삭제되었습니다.", id };
|
||||
});
|
||||
}
|
||||
|
||||
// ── STT / Transcription Provider Endpoints ────────────────────────────
|
||||
|
|
@ -163,45 +216,88 @@ public class AdminController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost("stt-endpoints")]
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> CreateSttEndpoint([FromBody] CreateSttEndpointDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name) || string.IsNullOrWhiteSpace(dto.EndpointUrl))
|
||||
if (string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl)
|
||||
|| dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1)
|
||||
{
|
||||
return BadRequest(new { message = "이름과 Endpoint URL은 필수입니다." });
|
||||
return BadRequest(new { message = "유효한 STT 이름, HTTP(S) URL, 비용, 우선순위가 필요합니다." });
|
||||
}
|
||||
|
||||
var endpoint = await _sttService.CreateEndpointAsync(dto);
|
||||
return Ok(endpoint);
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"stt_endpoint.create",
|
||||
dto,
|
||||
"stt_endpoint",
|
||||
result => ((SttProviderEndpointDto)result).Id.ToString(),
|
||||
dto.Memo,
|
||||
() => Task.FromResult<object?>(null),
|
||||
async () => await _sttService.CreateEndpointAsync(dto));
|
||||
}
|
||||
|
||||
[HttpPut("stt-endpoints/{id}")]
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> UpdateSttEndpoint(int id, [FromBody] UpdateSttEndpointDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var endpoint = await _sttService.UpdateEndpointAsync(id, dto);
|
||||
return Ok(endpoint);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound(new { message = $"STT Endpoint {id} not found." });
|
||||
}
|
||||
if (id <= 0 || string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl)
|
||||
|| dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1)
|
||||
return BadRequest(new { message = "유효한 STT 엔드포인트 값이 필요합니다." });
|
||||
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"stt_endpoint.update",
|
||||
new { id, dto },
|
||||
"stt_endpoint",
|
||||
_ => id.ToString(),
|
||||
dto.Memo,
|
||||
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||
.Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority })
|
||||
.SingleOrDefaultAsync(),
|
||||
async () =>
|
||||
{
|
||||
try { return await _sttService.UpdateEndpointAsync(id, dto); }
|
||||
catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); }
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("stt-endpoints/{id}")]
|
||||
public async Task<IActionResult> DeleteSttEndpoint(int id)
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> DeleteSttEndpoint(int id, [FromBody] AdminActionDto dto)
|
||||
{
|
||||
var deleted = await _sttService.DeleteEndpointAsync(id);
|
||||
if (!deleted) return NotFound();
|
||||
return Ok(new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다." });
|
||||
if (id <= 0) return BadRequest();
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"stt_endpoint.delete",
|
||||
new { id, dto.Memo },
|
||||
"stt_endpoint",
|
||||
_ => id.ToString(),
|
||||
dto.Memo,
|
||||
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||
.Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority })
|
||||
.SingleOrDefaultAsync(),
|
||||
async () =>
|
||||
{
|
||||
if (!await _sttService.DeleteEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found");
|
||||
return new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다.", id };
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("stt-endpoints/{id}/set-default")]
|
||||
public async Task<IActionResult> SetDefaultSttEndpoint(int id)
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> SetDefaultSttEndpoint(int id, [FromBody] AdminActionDto dto)
|
||||
{
|
||||
var success = await _sttService.SetDefaultEndpointAsync(id);
|
||||
if (!success) return NotFound();
|
||||
return Ok(new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." });
|
||||
if (id <= 0) return BadRequest();
|
||||
return await ExecuteAdminMutationAsync(
|
||||
"stt_endpoint.set_default",
|
||||
new { id, dto.Memo },
|
||||
"stt_endpoint",
|
||||
_ => id.ToString(),
|
||||
dto.Memo,
|
||||
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.IsDefault)
|
||||
.Select(endpoint => new { endpoint.Id, endpoint.Name }).ToListAsync(),
|
||||
async () =>
|
||||
{
|
||||
if (!await _sttService.SetDefaultEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found");
|
||||
return new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." };
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("stt-endpoints/{id}/test")]
|
||||
|
|
@ -212,9 +308,15 @@ public class AdminController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost("stt-endpoints/test-direct")]
|
||||
public async Task<IActionResult> TestSttDirect([FromQuery] string endpointUrl, [FromQuery] string? apiKey)
|
||||
[Authorize(Policy = "AdminOrAbove")]
|
||||
public async Task<IActionResult> TestSttDirect([FromBody] DirectSttTestDto dto)
|
||||
{
|
||||
var result = await _sttService.TestEndpointAsync(0, apiKey, endpointUrl);
|
||||
if (!IsValidEndpointUrl(dto.EndpointUrl) || !Uri.TryCreate(dto.EndpointUrl, UriKind.Absolute, out var endpointUri))
|
||||
{
|
||||
return BadRequest(new { message = "유효한 HTTP(S) Endpoint URL이 필요합니다." });
|
||||
}
|
||||
|
||||
var result = await _sttService.TestEndpointAsync(0, dto.ApiKey, endpointUri.AbsoluteUri);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
|
|
@ -272,4 +374,113 @@ public class AdminController : ControllerBase
|
|||
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
// 오프라인 Ed25519 라이선스 발급 감사. admin 콘솔이 서명 후 이 엔드포인트로 기록만 남긴다.
|
||||
[HttpPost("license-audit")]
|
||||
[Authorize(Policy = "SuperAdminOnly")]
|
||||
[RequestSizeLimit(8 * 1024)]
|
||||
public async Task<IActionResult> RecordLicenseAudit([FromBody] LicenseAuditDto dto)
|
||||
{
|
||||
var actorEmail = User.FindFirstValue(ClaimTypes.Email)?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (actorEmail.Length is < 3 or > 150) return Unauthorized(new { error = "invalid_actor" });
|
||||
|
||||
var licenseId = dto.LicenseId?.Trim() ?? string.Empty;
|
||||
var customerEmail = dto.CustomerEmail?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
var tier = dto.Tier?.Trim() ?? string.Empty;
|
||||
var validity = dto.Validity?.Trim() ?? string.Empty;
|
||||
if (licenseId.Length is < 3 or > 200) return BadRequest(new { error = "invalid_license_id" });
|
||||
if (customerEmail.Length is < 3 or > 150 || !customerEmail.Contains('@')) return BadRequest(new { error = "invalid_customer_email" });
|
||||
if (tier is not ("pro" or "pro_plus" or "team" or "enterprise")) return BadRequest(new { error = "invalid_tier" });
|
||||
if (validity is not ("30d" or "365d" or "lifetime")) return BadRequest(new { error = "invalid_validity" });
|
||||
|
||||
var afterJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
licenseId,
|
||||
customerEmail,
|
||||
tier,
|
||||
validity,
|
||||
expiresAt = dto.ExpiresAt
|
||||
});
|
||||
|
||||
_db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||
{
|
||||
ActorEmail = actorEmail,
|
||||
Action = "license.issue",
|
||||
TargetType = "license",
|
||||
TargetId = licenseId,
|
||||
AfterJson = afterJson,
|
||||
Memo = $"Issued {tier} license ({validity}) for {customerEmail}",
|
||||
IdempotencyKey = Guid.NewGuid().ToString("D"),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
private static object ToModelEndpointDto(ServiceModelEndpoint endpoint) => new
|
||||
{
|
||||
endpoint.Id,
|
||||
endpoint.ModelId,
|
||||
endpoint.ModelName,
|
||||
endpoint.Provider,
|
||||
endpoint.EndpointUrl,
|
||||
ApiKey = string.IsNullOrWhiteSpace(endpoint.ApiKey) ? string.Empty : "••••••••",
|
||||
endpoint.CostPer1kPromptTokens,
|
||||
endpoint.CostPer1kCompletionTokens,
|
||||
endpoint.IsActive,
|
||||
endpoint.CreatedAt
|
||||
};
|
||||
|
||||
private static bool IsValidEndpointUrl(string? value)
|
||||
{
|
||||
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.UserInfo)) return false;
|
||||
if (uri.Scheme == Uri.UriSchemeHttp) return uri.IsLoopback;
|
||||
return uri.Scheme == Uri.UriSchemeHttps;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ExecuteAdminMutationAsync(
|
||||
string operation,
|
||||
object request,
|
||||
string targetType,
|
||||
Func<object, string> targetId,
|
||||
string? memo,
|
||||
Func<Task<object?>> readBefore,
|
||||
Func<Task<object>> mutate)
|
||||
{
|
||||
var actorEmail = User.FindFirstValue(ClaimTypes.Email);
|
||||
if (string.IsNullOrWhiteSpace(actorEmail)) return Unauthorized(new { message = "관리자 이메일 claim이 필요합니다." });
|
||||
|
||||
var idempotencyKey = Request.Headers["Idempotency-Key"].ToString();
|
||||
try
|
||||
{
|
||||
var result = await _adminOperations.ExecuteAsync(
|
||||
actorEmail,
|
||||
operation,
|
||||
idempotencyKey,
|
||||
request,
|
||||
targetType,
|
||||
targetId,
|
||||
memo ?? string.Empty,
|
||||
readBefore,
|
||||
mutate);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (AdminOperationException ex) when (ex.Message == "idempotency_key_reused_with_different_request")
|
||||
{
|
||||
return Conflict(new { message = ex.Message });
|
||||
}
|
||||
catch (AdminOperationException ex) when (ex.Message.EndsWith("_not_found", StringComparison.Ordinal))
|
||||
{
|
||||
return NotFound(new { message = ex.Message });
|
||||
}
|
||||
catch (AdminOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return Conflict(new { message = "admin_operation_conflict" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ public record LoginDto(string Email, string Password);
|
|||
public record AuthResponseDto(string Token, string Email, string Role, DateTime ExpiresAt);
|
||||
public record UserInfoDto(int Id, string Email, string Role, DateTime CreatedAt, DateTime? LastLoginAt, bool IsActive);
|
||||
|
||||
// License issuance audit trail (offline Ed25519 license keys issued from the admin console)
|
||||
public record LicenseAuditDto(
|
||||
string LicenseId,
|
||||
string CustomerEmail,
|
||||
string Tier,
|
||||
string Validity,
|
||||
long? ExpiresAt);
|
||||
|
||||
// LLM DTOs
|
||||
public record LlmGenerateRequest(string Prompt, string? Model = null, string? SystemPrompt = null, double Temperature = 0.7, int MaxTokens = 2048);
|
||||
public record LlmGenerateResponse(string Text, string Model, int PromptTokens, int CompletionTokens, double TotalDurationMs, decimal Cost);
|
||||
|
|
@ -23,7 +31,8 @@ public record CreateModelEndpointDto(
|
|||
string EndpointUrl,
|
||||
string ApiKey,
|
||||
decimal CostPer1kPromptTokens,
|
||||
decimal CostPer1kCompletionTokens
|
||||
decimal CostPer1kCompletionTokens,
|
||||
string? Memo = null
|
||||
);
|
||||
|
||||
public record UpdateModelEndpointDto(
|
||||
|
|
@ -33,7 +42,8 @@ public record UpdateModelEndpointDto(
|
|||
string ApiKey,
|
||||
decimal CostPer1kPromptTokens,
|
||||
decimal CostPer1kCompletionTokens,
|
||||
bool IsActive
|
||||
bool IsActive,
|
||||
string? Memo = null
|
||||
);
|
||||
|
||||
public record ServerStatsDto(
|
||||
|
|
@ -117,7 +127,8 @@ public record CreateSttEndpointDto(
|
|||
bool IsDefault,
|
||||
bool IsActive,
|
||||
int FallbackPriority,
|
||||
string? ExtraHeadersJson
|
||||
string? ExtraHeadersJson,
|
||||
string? Memo = null
|
||||
);
|
||||
|
||||
public record UpdateSttEndpointDto(
|
||||
|
|
@ -135,9 +146,12 @@ public record UpdateSttEndpointDto(
|
|||
bool IsDefault,
|
||||
bool IsActive,
|
||||
int FallbackPriority,
|
||||
string? ExtraHeadersJson
|
||||
string? ExtraHeadersJson,
|
||||
string? Memo = null
|
||||
);
|
||||
|
||||
public record AdminActionDto(string? Memo);
|
||||
|
||||
public record SttTestResultDto(
|
||||
bool Success,
|
||||
string Message,
|
||||
|
|
@ -147,6 +161,8 @@ public record SttTestResultDto(
|
|||
string? ModelId
|
||||
);
|
||||
|
||||
public record DirectSttTestDto(string EndpointUrl, string? ApiKey);
|
||||
|
||||
public record SttUsageReportDto(
|
||||
int TotalTranscriptions,
|
||||
double TotalAudioMinutes,
|
||||
|
|
@ -172,4 +188,3 @@ public record SttUserUsageSummaryDto(
|
|||
double TotalAudioMinutes,
|
||||
decimal TotalCost
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ 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;
|
||||
|
|
@ -13,6 +15,19 @@ var serverStartTime = DateTime.UtcNow;
|
|||
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 =>
|
||||
{
|
||||
|
|
@ -45,11 +60,24 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
|||
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:SecretKey"]
|
||||
?? builder.Configuration["JWT_SECRET"]
|
||||
?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
|
||||
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 =>
|
||||
|
|
@ -59,25 +87,103 @@ builder.Services.AddAuthentication(options =>
|
|||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
|
||||
options.SaveToken = true;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
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("AllowAll", policy =>
|
||||
options.AddPolicy("ConfiguredOrigins", policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
policy.WithOrigins(corsOrigins)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
.AllowAnyHeader()
|
||||
.SetPreflightMaxAge(TimeSpan.FromHours(1));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -88,6 +194,75 @@ 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())
|
||||
|
|
@ -205,47 +380,78 @@ using (var scope = app.Services.CreateScope())
|
|||
db.SaveChanges();
|
||||
}
|
||||
|
||||
// Default Admin User seed & update password to Test1234!
|
||||
var adminUser = db.Users.FirstOrDefault(u => u.Email == "admin" || u.Email == "admin@d3ro.voice");
|
||||
var passwordHash = AuthService.HashPassword("Test1234!");
|
||||
if (adminUser == null)
|
||||
{
|
||||
db.Users.AddRange(
|
||||
new User
|
||||
{
|
||||
Email = "admin",
|
||||
PasswordHash = passwordHash,
|
||||
Role = "SuperAdmin",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
},
|
||||
new User
|
||||
{
|
||||
Email = "admin@d3ro.voice",
|
||||
PasswordHash = passwordHash,
|
||||
Role = "SuperAdmin",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
}
|
||||
);
|
||||
db.SaveChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
adminUser.PasswordHash = passwordHash;
|
||||
adminUser.Role = "SuperAdmin";
|
||||
adminUser.IsActive = true;
|
||||
db.SaveChanges();
|
||||
}
|
||||
// Administrative accounts are never seeded with repository credentials.
|
||||
// Provisioning is performed through the authenticated one-time bootstrap flow.
|
||||
}
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors("AllowAll");
|
||||
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();
|
||||
|
||||
|
|
@ -272,7 +478,10 @@ app.MapGet("/api/health", () => Results.Ok(new
|
|||
|
||||
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 { }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue