d3ro-voice/apps/api-server/Controllers/SttController.cs
2026-08-29 18:33:45 +09:00

131 lines
4.6 KiB
C#

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
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, IConfiguration configuration)
{
_sttService = sttService;
_configuration = configuration;
}
[HttpPost("transcribe")]
[Consumes("application/json", "multipart/form-data")]
public Task<IActionResult> Transcribe()
{
// 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" }));
}
[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)
{
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "stt_gateway_not_configured" });
}
var suppliedToken = Request.Headers[InternalGatewayHeader].ToString();
if (!FixedTimeTokenEquals(configuredToken, suppliedToken))
{
return Unauthorized(new { error = "stt_gateway_unauthorized" });
}
if (!Request.HasFormContentType)
{
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new { error = "unsupported_media_type" });
}
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" });
}
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: 0,
userEmail: "edge-internal",
request,
memoryStream.ToArray(),
file.ContentType ?? "application/octet-stream",
file.FileName ?? "recording.bin",
recordUsage: false);
return Ok(result);
}
catch (SttProviderUnavailableException)
{
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();
return Ok(endpoints);
}
[HttpPost("test")]
[Authorize(Policy = "ManagerOrAbove")]
public async Task<IActionResult> TestConnection([FromQuery] int endpointId = 0)
{
var result = await _sttService.TestEndpointAsync(endpointId);
return Ok(result);
}
}