feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,10 +1,14 @@
|
|||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace D3ROVoice.Api.Controllers;
|
||||
|
||||
|
|
@ -13,18 +17,45 @@ namespace D3ROVoice.Api.Controllers;
|
|||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AuthController(IAuthService authService)
|
||||
public AuthController(IAuthService authService, IConfiguration configuration)
|
||||
{
|
||||
_authService = authService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterDto dto)
|
||||
[EnableRateLimiting("auth")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public async Task<IActionResult> Register(
|
||||
[FromBody] RegisterDto dto,
|
||||
[FromHeader(Name = "X-D3RO-Bootstrap-Token")] string? bootstrapToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Email) || string.IsNullOrWhiteSpace(dto.Password))
|
||||
var normalizedEmail = dto.Email?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (
|
||||
normalizedEmail.Length is < 3 or > 150
|
||||
|| !MailAddress.TryCreate(normalizedEmail, out var parsedEmail)
|
||||
|| !string.Equals(parsedEmail.Address, normalizedEmail, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.IsNullOrWhiteSpace(dto.Password)
|
||||
)
|
||||
{
|
||||
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
|
||||
return BadRequest(new { message = "유효한 이메일과 비밀번호를 입력해주세요." });
|
||||
}
|
||||
|
||||
var configuredToken = _configuration["ADMIN_BOOTSTRAP_TOKEN"];
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(configuredToken)
|
||||
|| Encoding.UTF8.GetByteCount(configuredToken) < 32
|
||||
)
|
||||
{
|
||||
return StatusCode(503, new { message = "관리자 초기 등록이 비활성화되어 있습니다." });
|
||||
}
|
||||
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configuredToken));
|
||||
var providedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(bootstrapToken ?? string.Empty));
|
||||
if (!CryptographicOperations.FixedTimeEquals(configuredDigest, providedDigest))
|
||||
{
|
||||
return Unauthorized(new { message = "관리자 초기 등록 토큰이 올바르지 않습니다." });
|
||||
}
|
||||
|
||||
try
|
||||
|
|
@ -39,9 +70,17 @@ public class AuthController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[EnableRateLimiting("auth")]
|
||||
[RequestSizeLimit(16 * 1024)]
|
||||
public async Task<IActionResult> Login([FromBody] LoginDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Email) || string.IsNullOrWhiteSpace(dto.Password))
|
||||
var normalizedEmail = dto.Email?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (
|
||||
normalizedEmail.Length is < 3 or > 150
|
||||
|| !MailAddress.TryCreate(normalizedEmail, out _)
|
||||
|| string.IsNullOrWhiteSpace(dto.Password)
|
||||
|| dto.Password.Length > 256
|
||||
)
|
||||
{
|
||||
return BadRequest(new { message = "이메일과 비밀번호를 입력해주세요." });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +1,120 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using D3ROVoice.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace D3ROVoice.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
public class SttController : ControllerBase
|
||||
{
|
||||
private const string InternalGatewayHeader = "X-D3RO-STT-Gateway-Token";
|
||||
private readonly ISttProxyService _sttService;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public SttController(ISttProxyService sttService)
|
||||
public SttController(ISttProxyService sttService, IConfiguration configuration)
|
||||
{
|
||||
_sttService = sttService;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpPost("transcribe")]
|
||||
[Consumes("application/json", "multipart/form-data")]
|
||||
public async Task<IActionResult> Transcribe()
|
||||
public Task<IActionResult> Transcribe()
|
||||
{
|
||||
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var userEmail = User.FindFirstValue(ClaimTypes.Email) ?? "user@d3ro.voice";
|
||||
int userId = int.TryParse(userIdStr, out var id) ? id : 1;
|
||||
// User-facing transcription is exclusively handled by the Supabase
|
||||
// stt-proxy, which owns authenticated identity, quota reservation and
|
||||
// usage persistence. This legacy provider path must not bypass it.
|
||||
return Task.FromResult<IActionResult>(StatusCode(
|
||||
StatusCodes.Status410Gone,
|
||||
new { error = "stt_edge_gateway_required" }));
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
[HttpPost("internal/transcribe")]
|
||||
[AllowAnonymous]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(26 * 1024 * 1024)]
|
||||
public async Task<IActionResult> TranscribeFromQuotaGateway()
|
||||
{
|
||||
var configuredToken = _configuration["D3RO_API_TOKEN"]?.Trim() ?? string.Empty;
|
||||
if (Encoding.UTF8.GetByteCount(configuredToken) < 32)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_gateway_not_configured" });
|
||||
}
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { message = "전송할 오디오 파일(file 또는 audio)이 필요합니다." });
|
||||
}
|
||||
var suppliedToken = Request.Headers[InternalGatewayHeader].ToString();
|
||||
if (!FixedTimeTokenEquals(configuredToken, suppliedToken))
|
||||
{
|
||||
return Unauthorized(new { error = "stt_gateway_unauthorized" });
|
||||
}
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream);
|
||||
var audioBytes = memoryStream.ToArray();
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported_media_type" });
|
||||
}
|
||||
|
||||
var language = form["language"].ToString();
|
||||
var prompt = form["prompt"].ToString();
|
||||
var model = form["model"].ToString();
|
||||
var provider = form["provider"].ToString();
|
||||
var form = await Request.ReadFormAsync();
|
||||
var file = form.Files.GetFile("file") ?? form.Files.GetFile("audio");
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "missing_audio" });
|
||||
}
|
||||
|
||||
var request = new SttTranscribeRequest(
|
||||
AudioBase64: null,
|
||||
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
|
||||
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
|
||||
ModelId: string.IsNullOrWhiteSpace(model) ? null : model,
|
||||
Provider: string.IsNullOrWhiteSpace(provider) ? null : provider
|
||||
);
|
||||
await using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream);
|
||||
var language = form["language"].ToString();
|
||||
var prompt = form["prompt"].ToString();
|
||||
var request = new SttTranscribeRequest(
|
||||
AudioBase64: null,
|
||||
Language: string.IsNullOrWhiteSpace(language) ? "ko" : language,
|
||||
InitialPrompt: string.IsNullOrWhiteSpace(prompt) ? null : prompt,
|
||||
ModelId: null,
|
||||
Provider: null);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _sttService.TranscribeAsync(
|
||||
userId,
|
||||
userEmail,
|
||||
userId: 0,
|
||||
userEmail: "edge-internal",
|
||||
request,
|
||||
audioBytes,
|
||||
file.ContentType ?? "audio/webm",
|
||||
file.FileName ?? "recording.webm"
|
||||
);
|
||||
|
||||
memoryStream.ToArray(),
|
||||
file.ContentType ?? "application/octet-stream",
|
||||
file.FileName ?? "recording.bin",
|
||||
recordUsage: false);
|
||||
return Ok(result);
|
||||
}
|
||||
else
|
||||
catch (SttProviderUnavailableException)
|
||||
{
|
||||
// Read JSON body
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return BadRequest(new { message = "요청 본문이 비어있습니다." });
|
||||
}
|
||||
|
||||
var request = System.Text.Json.JsonSerializer.Deserialize<SttTranscribeRequest>(
|
||||
json,
|
||||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }
|
||||
);
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.AudioBase64))
|
||||
{
|
||||
return BadRequest(new { message = "AudioBase64 데이터가 필요합니다." });
|
||||
}
|
||||
|
||||
var result = await _sttService.TranscribeAsync(userId, userEmail, request);
|
||||
return Ok(result);
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_provider_unavailable" });
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_audio" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "stt_upstream_failed" });
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FixedTimeTokenEquals(string configured, string supplied)
|
||||
{
|
||||
var configuredDigest = SHA256.HashData(Encoding.UTF8.GetBytes(configured));
|
||||
var suppliedDigest = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
|
||||
return CryptographicOperations.FixedTimeEquals(configuredDigest, suppliedDigest);
|
||||
}
|
||||
|
||||
[HttpGet("providers")]
|
||||
[Authorize(Policy = "ManagerOrAbove")]
|
||||
public async Task<IActionResult> GetActiveProviders()
|
||||
{
|
||||
var endpoints = await _sttService.GetAllEndpointsAsync();
|
||||
|
|
@ -101,6 +122,7 @@ public class SttController : ControllerBase
|
|||
}
|
||||
|
||||
[HttpPost("test")]
|
||||
[Authorize(Policy = "ManagerOrAbove")]
|
||||
public async Task<IActionResult> TestConnection([FromQuery] int endpointId = 0)
|
||||
{
|
||||
var result = await _sttService.TestEndpointAsync(endpointId);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue