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);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,24 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>1.1.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Retained legacy binaries are not static assets and must never publish. -->
|
||||
<Content Remove="wwwroot\releases\**\*" />
|
||||
<Content Remove="wwwroot\index.html" />
|
||||
<Content Remove="wwwroot\assets\index-D7M5UQvT.js" />
|
||||
<Content Remove="wwwroot\assets\index-JlYFxlAJ.js" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.13" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -203,6 +203,58 @@ public class SttUsageLog
|
|||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AdminOperationRequest
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required, MaxLength(150)]
|
||||
public string ActorEmail { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(36)]
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string Operation { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(64)]
|
||||
public string RequestHash { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ResponseJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AdminAuditEntry
|
||||
{
|
||||
[Key]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required, MaxLength(150)]
|
||||
public string ActorEmail { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(80)]
|
||||
public string TargetType { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string TargetId { get; set; } = string.Empty;
|
||||
|
||||
public string? BeforeJson { get; set; }
|
||||
public string? AfterJson { get; set; }
|
||||
|
||||
[Required, MaxLength(1000)]
|
||||
public string Memo { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(36)]
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
|
@ -213,6 +265,8 @@ public class AppDbContext : DbContext
|
|||
public DbSet<ServerErrorLog> ErrorLogs => Set<ServerErrorLog>();
|
||||
public DbSet<SttProviderEndpoint> SttProviderEndpoints => Set<SttProviderEndpoint>();
|
||||
public DbSet<SttUsageLog> SttUsageLogs => Set<SttUsageLog>();
|
||||
public DbSet<AdminOperationRequest> AdminOperationRequests => Set<AdminOperationRequest>();
|
||||
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
|
@ -231,5 +285,12 @@ public class AppDbContext : DbContext
|
|||
|
||||
modelBuilder.Entity<SttProviderEndpoint>()
|
||||
.HasIndex(s => s.IsDefault);
|
||||
|
||||
modelBuilder.Entity<AdminOperationRequest>()
|
||||
.HasIndex(operation => new { operation.ActorEmail, operation.IdempotencyKey })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<AdminAuditEntry>()
|
||||
.HasIndex(entry => entry.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Multi-stage Docker build for D3RO Voice C# .NET API Backend & BackOffice
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.302-noble AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY D3ROVoice.Api.csproj ./
|
||||
|
|
@ -8,7 +8,7 @@ RUN dotnet restore
|
|||
COPY . ./
|
||||
RUN dotnet publish -c Release -o /app/out
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.10-noble AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Create persistent storage directory for SQLite database
|
||||
|
|
@ -24,4 +24,3 @@ ENV DATA_DIR=/app/data
|
|||
VOLUME ["/app/data"]
|
||||
|
||||
ENTRYPOINT ["dotnet", "D3ROVoice.Api.dll"]
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ 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();
|
||||
|
|
@ -34,7 +36,7 @@ builder.Services.AddSwaggerGen(c =>
|
|||
c.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "D3RO Voice Cloud API & BackOffice",
|
||||
Version = "v1",
|
||||
Version = releaseVersion,
|
||||
Description = "D3RO Voice Self-Hosted Cloud Backend for NAS & Docker"
|
||||
});
|
||||
});
|
||||
|
|
@ -460,7 +462,7 @@ app.MapGet("/health", () => Results.Ok(new
|
|||
{
|
||||
status = "Healthy",
|
||||
service = "D3RO Voice Cloud API",
|
||||
version = "1.0.0",
|
||||
version = releaseVersion,
|
||||
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
||||
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
||||
timestamp = DateTime.UtcNow
|
||||
|
|
@ -470,7 +472,7 @@ app.MapGet("/api/health", () => Results.Ok(new
|
|||
{
|
||||
status = "Healthy",
|
||||
service = "D3RO Voice Cloud API",
|
||||
version = "1.0.0",
|
||||
version = releaseVersion,
|
||||
uptimeSeconds = (DateTime.UtcNow - serverStartTime).TotalSeconds,
|
||||
database = File.Exists(dbPath) ? "Connected" : "Initializing",
|
||||
timestamp = DateTime.UtcNow
|
||||
|
|
|
|||
102
apps/api-server/Services/AdminOperationService.cs
Normal file
102
apps/api-server/Services/AdminOperationService.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using D3ROVoice.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace D3ROVoice.Api.Services;
|
||||
|
||||
public sealed class AdminOperationException : Exception
|
||||
{
|
||||
public AdminOperationException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public interface IAdminOperationService
|
||||
{
|
||||
Task<JsonElement> ExecuteAsync(
|
||||
string actorEmail,
|
||||
string operation,
|
||||
string idempotencyKey,
|
||||
object request,
|
||||
string targetType,
|
||||
Func<object, string> targetId,
|
||||
string memo,
|
||||
Func<Task<object?>> readBefore,
|
||||
Func<Task<object>> mutate);
|
||||
}
|
||||
|
||||
public sealed class AdminOperationService : IAdminOperationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AdminOperationService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<JsonElement> ExecuteAsync(
|
||||
string actorEmail,
|
||||
string operation,
|
||||
string idempotencyKey,
|
||||
object request,
|
||||
string targetType,
|
||||
Func<object, string> targetId,
|
||||
string memo,
|
||||
Func<Task<object?>> readBefore,
|
||||
Func<Task<object>> mutate)
|
||||
{
|
||||
actorEmail = actorEmail.Trim().ToLowerInvariant();
|
||||
memo = memo.Trim();
|
||||
if (actorEmail.Length is < 3 or > 150) throw new AdminOperationException("invalid_actor");
|
||||
if (!Guid.TryParseExact(idempotencyKey, "D", out _)) throw new AdminOperationException("invalid_idempotency_key");
|
||||
if (memo.Length is < 3 or > 1000) throw new AdminOperationException("invalid_audit_memo");
|
||||
|
||||
var requestJson = JsonSerializer.Serialize(request);
|
||||
var requestHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{operation}:{requestJson}")));
|
||||
|
||||
await using var transaction = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
|
||||
var existing = await _db.AdminOperationRequests.AsNoTracking().SingleOrDefaultAsync(entry =>
|
||||
entry.ActorEmail == actorEmail && entry.IdempotencyKey == idempotencyKey);
|
||||
if (existing != null)
|
||||
{
|
||||
if (existing.Operation != operation || existing.RequestHash != requestHash)
|
||||
throw new AdminOperationException("idempotency_key_reused_with_different_request");
|
||||
using var replay = JsonDocument.Parse(existing.ResponseJson);
|
||||
return replay.RootElement.Clone();
|
||||
}
|
||||
|
||||
var before = await readBefore();
|
||||
var result = await mutate();
|
||||
var responseJson = JsonSerializer.Serialize(result);
|
||||
var resolvedTargetId = targetId(result);
|
||||
if (string.IsNullOrWhiteSpace(resolvedTargetId) || resolvedTargetId.Length > 200)
|
||||
throw new AdminOperationException("invalid_audit_target");
|
||||
|
||||
_db.AdminOperationRequests.Add(new AdminOperationRequest
|
||||
{
|
||||
ActorEmail = actorEmail,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
Operation = operation,
|
||||
RequestHash = requestHash,
|
||||
ResponseJson = responseJson,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
_db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||
{
|
||||
ActorEmail = actorEmail,
|
||||
Action = operation,
|
||||
TargetType = targetType,
|
||||
TargetId = resolvedTargetId,
|
||||
BeforeJson = before == null ? null : JsonSerializer.Serialize(before),
|
||||
AfterJson = responseJson,
|
||||
Memo = memo,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
using var response = JsonDocument.Parse(responseJson);
|
||||
return response.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using D3ROVoice.Api.Data;
|
||||
using D3ROVoice.Api.Dtos;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace D3ROVoice.Api.Services;
|
||||
|
|
@ -21,43 +21,81 @@ public interface IAuthService
|
|||
|
||||
public class AuthService : IAuthService
|
||||
{
|
||||
private static readonly SemaphoreSlim BootstrapLock = new(1, 1);
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||
private readonly string _dummyPasswordHash;
|
||||
|
||||
public AuthService(AppDbContext db, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_dummyPasswordHash = _passwordHasher.HashPassword(new User(), "dummy-password-never-used");
|
||||
}
|
||||
|
||||
public async Task<AuthResponseDto> RegisterAsync(RegisterDto dto)
|
||||
{
|
||||
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
|
||||
if (existing != null)
|
||||
if (dto.Password.Length < 12 || dto.Password.Length > 256)
|
||||
{
|
||||
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
|
||||
throw new InvalidOperationException("비밀번호는 12자 이상 256자 이하여야 합니다.");
|
||||
}
|
||||
|
||||
var isFirstUser = !await _db.Users.AnyAsync();
|
||||
var user = new User
|
||||
await BootstrapLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
Email = dto.Email.Trim().ToLower(),
|
||||
PasswordHash = HashPassword(dto.Password),
|
||||
Role = isFirstUser ? "Admin" : "User",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
|
||||
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
|
||||
if (existing != null)
|
||||
{
|
||||
throw new InvalidOperationException("이미 등록된 이메일 주소입니다.");
|
||||
}
|
||||
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
if (await _db.Users.AnyAsync(u => u.IsActive))
|
||||
{
|
||||
throw new InvalidOperationException("관리자 초기 등록이 이미 완료되었습니다.");
|
||||
}
|
||||
|
||||
return GenerateToken(user);
|
||||
var user = new User
|
||||
{
|
||||
Email = normalizedEmail,
|
||||
Role = "SuperAdmin",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsActive = true
|
||||
};
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
|
||||
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return GenerateToken(user);
|
||||
}
|
||||
finally
|
||||
{
|
||||
BootstrapLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AuthResponseDto> LoginAsync(LoginDto dto)
|
||||
{
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == dto.Email.ToLower());
|
||||
if (user == null || !VerifyPassword(dto.Password, user.PasswordHash))
|
||||
var normalizedEmail = dto.Email.Trim().ToLowerInvariant();
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email.ToLower() == normalizedEmail);
|
||||
if (user == null)
|
||||
{
|
||||
_passwordHasher.VerifyHashedPassword(new User(), _dummyPasswordHash, dto.Password);
|
||||
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
PasswordVerificationResult verification;
|
||||
try
|
||||
{
|
||||
verification = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, dto.Password);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
verification = PasswordVerificationResult.Failed;
|
||||
}
|
||||
if (verification == PasswordVerificationResult.Failed)
|
||||
{
|
||||
throw new UnauthorizedAccessException("이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||
}
|
||||
|
|
@ -68,6 +106,10 @@ public class AuthService : IAuthService
|
|||
}
|
||||
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, dto.Password);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return GenerateToken(user);
|
||||
|
|
@ -82,7 +124,21 @@ public class AuthService : IAuthService
|
|||
|
||||
private AuthResponseDto GenerateToken(User user)
|
||||
{
|
||||
var secretKey = _config["Jwt:SecretKey"] ?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
|
||||
var secretKey = _config["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 issuer = _config["JWT_ISSUER"];
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
throw new InvalidOperationException("JWT_ISSUER is required.");
|
||||
}
|
||||
var audience = _config["JWT_AUDIENCE"];
|
||||
if (string.IsNullOrWhiteSpace(audience))
|
||||
{
|
||||
throw new InvalidOperationException("JWT_AUDIENCE is required.");
|
||||
}
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
|
|
@ -93,11 +149,11 @@ public class AuthService : IAuthService
|
|||
new Claim(ClaimTypes.Role, user.Role)
|
||||
};
|
||||
|
||||
var expiresAt = DateTime.UtcNow.AddDays(30);
|
||||
var expiresAt = DateTime.UtcNow.AddHours(8);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _config["Jwt:Issuer"] ?? "D3ROVoiceApi",
|
||||
audience: _config["Jwt:Audience"] ?? "D3ROVoiceClient",
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: creds
|
||||
|
|
@ -107,15 +163,4 @@ public class AuthService : IAuthService
|
|||
return new AuthResponseDto(tokenHandler.WriteToken(token), user.Email, user.Role, expiresAt);
|
||||
}
|
||||
|
||||
public static string HashPassword(string password)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "D3RO_SALT_2026"));
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
return HashPassword(password) == hash;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ public interface ISttProxyService
|
|||
SttTranscribeRequest request,
|
||||
byte[]? audioBytes = null,
|
||||
string? contentType = null,
|
||||
string? fileName = null
|
||||
string? fileName = null,
|
||||
bool recordUsage = true
|
||||
);
|
||||
|
||||
Task<SttTestResultDto> TestEndpointAsync(int endpointId, string? testApiKey = null, string? testEndpointUrl = null);
|
||||
|
|
@ -35,6 +36,17 @@ public interface ISttProxyService
|
|||
Task<SttUsageReportDto> GetUsageReportAsync();
|
||||
}
|
||||
|
||||
public sealed class SttProviderUnavailableException : Exception
|
||||
{
|
||||
public bool ProviderAttempted { get; }
|
||||
|
||||
public SttProviderUnavailableException(bool providerAttempted)
|
||||
: base("STT service is temporarily unavailable.")
|
||||
{
|
||||
ProviderAttempted = providerAttempted;
|
||||
}
|
||||
}
|
||||
|
||||
public class SttProxyService : ISttProxyService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
|
@ -54,7 +66,8 @@ public class SttProxyService : ISttProxyService
|
|||
SttTranscribeRequest request,
|
||||
byte[]? audioBytes = null,
|
||||
string? contentType = null,
|
||||
string? fileName = null
|
||||
string? fileName = null,
|
||||
bool recordUsage = true
|
||||
)
|
||||
{
|
||||
// 1. Resolve Audio Bytes
|
||||
|
|
@ -94,23 +107,12 @@ public class SttProxyService : ISttProxyService
|
|||
var candidates = await GetCandidateEndpointsAsync(request.Provider, request.ModelId);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
// Seed a dynamic fallback endpoint in memory
|
||||
candidates.Add(new SttProviderEndpoint
|
||||
{
|
||||
Id = 0,
|
||||
Name = "Default Groq Whisper Fallback",
|
||||
ProviderType = "groq",
|
||||
EndpointUrl = "https://api.groq.com/openai/v1/audio/transcriptions",
|
||||
ModelId = "whisper-large-v3-turbo",
|
||||
Method = "multipart",
|
||||
Language = request.Language ?? "ko",
|
||||
CostPerMinute = 0.000500m,
|
||||
IsActive = true
|
||||
});
|
||||
throw new SttProviderUnavailableException(providerAttempted: false);
|
||||
}
|
||||
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
Exception? lastException = null;
|
||||
var providerAttempted = false;
|
||||
|
||||
// 3. Failover Execution Chain
|
||||
foreach (var endpoint in candidates)
|
||||
|
|
@ -118,6 +120,14 @@ public class SttProxyService : ISttProxyService
|
|||
var epSw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
if (!HasRequiredProviderConfiguration(endpoint))
|
||||
{
|
||||
_logger.LogWarning("Skipping STT provider {Provider}: required credentials are not configured",
|
||||
endpoint.ProviderType);
|
||||
continue;
|
||||
}
|
||||
|
||||
providerAttempted = true;
|
||||
_logger.LogInformation("Attempting STT transcription via provider {Provider} ({Name}, Model: {Model})",
|
||||
endpoint.ProviderType, endpoint.Name, endpoint.ModelId);
|
||||
|
||||
|
|
@ -136,29 +146,33 @@ public class SttProxyService : ISttProxyService
|
|||
var durationMinutes = (decimal)(finalDuration / 60.0);
|
||||
var cost = Math.Max(0.000001m, durationMinutes * endpoint.CostPerMinute);
|
||||
|
||||
// Record Usage Log
|
||||
try
|
||||
// Supabase stt-proxy owns user quota and usage for internal
|
||||
// provider calls. Direct legacy callers may still record here.
|
||||
if (recordUsage)
|
||||
{
|
||||
var usageLog = new SttUsageLog
|
||||
try
|
||||
{
|
||||
UserId = userId,
|
||||
UserEmail = userEmail,
|
||||
EndpointId = endpoint.Id,
|
||||
Provider = endpoint.ProviderType,
|
||||
ModelId = endpoint.ModelId,
|
||||
AudioDurationSeconds = Math.Round(finalDuration, 2),
|
||||
CalculatedCost = cost,
|
||||
LatencyMs = (int)epSw.ElapsedMilliseconds,
|
||||
StatusCode = 200,
|
||||
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.SttUsageLogs.Add(usageLog);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception dbEx)
|
||||
{
|
||||
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
|
||||
var usageLog = new SttUsageLog
|
||||
{
|
||||
UserId = userId,
|
||||
UserEmail = userEmail,
|
||||
EndpointId = endpoint.Id,
|
||||
Provider = endpoint.ProviderType,
|
||||
ModelId = endpoint.ModelId,
|
||||
AudioDurationSeconds = Math.Round(finalDuration, 2),
|
||||
CalculatedCost = cost,
|
||||
LatencyMs = (int)epSw.ElapsedMilliseconds,
|
||||
StatusCode = 200,
|
||||
TranscriptPreview = transcript.Length > 200 ? transcript.Substring(0, 200) + "..." : transcript,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.SttUsageLogs.Add(usageLog);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception dbEx)
|
||||
{
|
||||
_logger.LogWarning(dbEx, "Failed to save STT usage log to database");
|
||||
}
|
||||
}
|
||||
|
||||
return new SttTranscribeResponse(
|
||||
|
|
@ -176,18 +190,19 @@ public class SttProxyService : ISttProxyService
|
|||
{
|
||||
epSw.Stop();
|
||||
lastException = ex;
|
||||
_logger.LogWarning(ex, "Provider {Provider} ({Name}) transcription failed after {Ms}ms. Trying fallback...",
|
||||
endpoint.ProviderType, endpoint.Name, epSw.ElapsedMilliseconds);
|
||||
var failureType = GetSafeFailureType(ex);
|
||||
_logger.LogWarning("Provider {Provider} transcription failed after {Ms}ms ({FailureType}). Trying fallback...",
|
||||
endpoint.ProviderType, epSw.ElapsedMilliseconds, failureType);
|
||||
|
||||
// Record error to ErrorLogs
|
||||
try
|
||||
{
|
||||
_db.ErrorLogs.Add(new ServerErrorLog
|
||||
{
|
||||
ErrorType = $"SttProviderError:{endpoint.ProviderType}",
|
||||
Message = $"STT failed on endpoint {endpoint.Name} ({endpoint.EndpointUrl}): {ex.Message}",
|
||||
StackTrace = ex.StackTrace,
|
||||
Endpoint = endpoint.EndpointUrl,
|
||||
ErrorType = "SttProviderError",
|
||||
Message = $"STT provider request failed ({failureType}).",
|
||||
StackTrace = null,
|
||||
Endpoint = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
|
@ -200,19 +215,9 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
|
||||
totalSw.Stop();
|
||||
_logger.LogError(lastException, "All STT candidate endpoints failed. Total duration: {Ms}ms", totalSw.ElapsedMilliseconds);
|
||||
|
||||
// Standalone graceful mock echo response if no cloud API keys configured or network is isolated
|
||||
return new SttTranscribeResponse(
|
||||
Text: $"[D3RO Cloud STT — Voice Transcribed] 음성 전사 완료 ({effectiveFileName}, {Math.Round(durationSeconds, 1)}초)",
|
||||
Confidence: 0.95,
|
||||
Language: request.Language ?? "ko",
|
||||
DurationSeconds: Math.Round(durationSeconds, 2),
|
||||
Provider: "fallback-local",
|
||||
ModelId: "whisper-local",
|
||||
LatencyMs: totalSw.ElapsedMilliseconds,
|
||||
Cost: 0m
|
||||
);
|
||||
_logger.LogError("All STT candidate endpoints failed. Total duration: {Ms}ms; last failure type: {FailureType}",
|
||||
totalSw.ElapsedMilliseconds, GetSafeFailureType(lastException));
|
||||
throw new SttProviderUnavailableException(providerAttempted);
|
||||
}
|
||||
|
||||
private async Task<(string Transcript, double Confidence, string? Language, double Duration)> ExecuteProviderTranscriptionAsync(
|
||||
|
|
@ -229,15 +234,9 @@ public class SttProxyService : ISttProxyService
|
|||
var providerType = endpoint.ProviderType.ToLowerInvariant();
|
||||
var apiKey = endpoint.ApiKey?.Trim() ?? "";
|
||||
|
||||
// If no API key configured or local mock
|
||||
if (string.IsNullOrWhiteSpace(apiKey) && providerType != "local-sidecar" && providerType != "custom")
|
||||
if (RequiresApiKey(providerType) && string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return (
|
||||
$"[D3RO Online Cloud STT - {endpoint.Name}] 음성 인식이 성공적으로 처리되었습니다.",
|
||||
0.99,
|
||||
request.Language ?? endpoint.Language ?? "ko",
|
||||
EstimateAudioDuration(audioBytes, contentType)
|
||||
);
|
||||
throw new InvalidOperationException("STT provider credentials are not configured.");
|
||||
}
|
||||
|
||||
switch (providerType)
|
||||
|
|
@ -325,17 +324,17 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"STT Upstream {endpoint.ProviderType} returned HTTP {httpResponse.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {httpResponse.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var text = "";
|
||||
if (root.TryGetProperty("text", out var textProp))
|
||||
if (!root.TryGetProperty("text", out var textProp) || textProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
text = textProp.GetString() ?? "";
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = textProp.GetString() ?? "";
|
||||
|
||||
var duration = 0.0;
|
||||
if (root.TryGetProperty("duration", out var durProp))
|
||||
|
|
@ -385,28 +384,29 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Deepgram returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var transcript = "";
|
||||
string transcript;
|
||||
var confidence = 0.95;
|
||||
var duration = 0.0;
|
||||
|
||||
if (root.TryGetProperty("results", out var results) &&
|
||||
results.TryGetProperty("channels", out var channels) &&
|
||||
channels.GetArrayLength() > 0)
|
||||
if (!root.TryGetProperty("results", out var results) ||
|
||||
!results.TryGetProperty("channels", out var channels) ||
|
||||
channels.GetArrayLength() == 0 ||
|
||||
!channels[0].TryGetProperty("alternatives", out var alternatives) ||
|
||||
alternatives.GetArrayLength() == 0 ||
|
||||
!alternatives[0].TryGetProperty("transcript", out var transcriptProperty) ||
|
||||
transcriptProperty.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
var ch0 = channels[0];
|
||||
if (ch0.TryGetProperty("alternatives", out var alts) && alts.GetArrayLength() > 0)
|
||||
{
|
||||
var alt0 = alts[0];
|
||||
if (alt0.TryGetProperty("transcript", out var tProp)) transcript = tProp.GetString() ?? "";
|
||||
if (alt0.TryGetProperty("confidence", out var cProp)) confidence = cProp.GetDouble();
|
||||
}
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
transcript = transcriptProperty.GetString() ?? "";
|
||||
if (alternatives[0].TryGetProperty("confidence", out var confidenceProperty))
|
||||
confidence = confidenceProperty.GetDouble();
|
||||
|
||||
if (root.TryGetProperty("metadata", out var meta) && meta.TryGetProperty("duration", out var dProp))
|
||||
{
|
||||
|
|
@ -431,9 +431,6 @@ public class SttProxyService : ISttProxyService
|
|||
if (endpoint.EndpointUrl.Contains("generativelanguage.googleapis.com") || endpoint.ModelId.Contains("gemini"))
|
||||
{
|
||||
var apiKey = endpoint.ApiKey;
|
||||
var url = endpoint.EndpointUrl.Contains("?")
|
||||
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
||||
: $"{endpoint.EndpointUrl}?key={apiKey}";
|
||||
|
||||
var geminiPayload = new
|
||||
{
|
||||
|
|
@ -460,32 +457,33 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
};
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(geminiPayload), Encoding.UTF8, "application/json")
|
||||
};
|
||||
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
|
||||
|
||||
var response = await client.SendAsync(httpRequest);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Google Gemini STT returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
var text = "";
|
||||
if (root.TryGetProperty("candidates", out var cands) && cands.GetArrayLength() > 0)
|
||||
if (!root.TryGetProperty("candidates", out var cands) ||
|
||||
cands.GetArrayLength() == 0 ||
|
||||
!cands[0].TryGetProperty("content", out var content) ||
|
||||
!content.TryGetProperty("parts", out var parts) ||
|
||||
parts.GetArrayLength() == 0 ||
|
||||
!parts[0].TryGetProperty("text", out var textProperty) ||
|
||||
textProperty.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
var cand0 = cands[0];
|
||||
if (cand0.TryGetProperty("content", out var content) &&
|
||||
content.TryGetProperty("parts", out var parts) &&
|
||||
parts.GetArrayLength() > 0)
|
||||
{
|
||||
text = parts[0].GetProperty("text").GetString() ?? "";
|
||||
}
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = textProperty.GetString() ?? "";
|
||||
|
||||
return (text.Trim(), 0.98, lang, EstimateAudioDuration(audioBytes, contentType));
|
||||
}
|
||||
|
|
@ -493,9 +491,6 @@ public class SttProxyService : ISttProxyService
|
|||
{
|
||||
// Google Cloud Speech-to-Text v1
|
||||
var apiKey = endpoint.ApiKey;
|
||||
var url = endpoint.EndpointUrl.Contains("?")
|
||||
? $"{endpoint.EndpointUrl}&key={apiKey}"
|
||||
: $"{endpoint.EndpointUrl}?key={apiKey}";
|
||||
|
||||
var gcpPayload = new
|
||||
{
|
||||
|
|
@ -512,17 +507,18 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
};
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint.EndpointUrl)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(gcpPayload), Encoding.UTF8, "application/json")
|
||||
};
|
||||
httpRequest.Headers.Add("X-Goog-Api-Key", apiKey);
|
||||
|
||||
var response = await client.SendAsync(httpRequest);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Google Cloud STT returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
|
|
@ -530,7 +526,12 @@ public class SttProxyService : ISttProxyService
|
|||
var transcript = "";
|
||||
var confidence = 0.95;
|
||||
|
||||
if (root.TryGetProperty("results", out var results) && results.GetArrayLength() > 0)
|
||||
if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain transcription results.");
|
||||
}
|
||||
|
||||
if (results.GetArrayLength() > 0)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var res in results.EnumerateArray())
|
||||
|
|
@ -568,7 +569,7 @@ public class SttProxyService : ISttProxyService
|
|||
var uploadBody = await uploadResp.Content.ReadAsStringAsync();
|
||||
if (!uploadResp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"AssemblyAI upload failed: {uploadBody}");
|
||||
throw new HttpRequestException($"STT upstream upload returned HTTP {uploadResp.StatusCode}.");
|
||||
}
|
||||
|
||||
using var uploadDoc = JsonDocument.Parse(uploadBody);
|
||||
|
|
@ -594,7 +595,7 @@ public class SttProxyService : ISttProxyService
|
|||
var transBody = await transResp.Content.ReadAsStringAsync();
|
||||
if (!transResp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"AssemblyAI transcript job failed: {transBody}");
|
||||
throw new HttpRequestException($"STT upstream job returned HTTP {transResp.StatusCode}.");
|
||||
}
|
||||
|
||||
using var transDoc = JsonDocument.Parse(transBody);
|
||||
|
|
@ -610,12 +611,22 @@ public class SttProxyService : ISttProxyService
|
|||
var pollResp = await client.SendAsync(pollReq);
|
||||
var pollBody = await pollResp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!pollResp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"STT upstream poll returned HTTP {pollResp.StatusCode}.");
|
||||
}
|
||||
|
||||
using var pollDoc = JsonDocument.Parse(pollBody);
|
||||
var status = pollDoc.RootElement.GetProperty("status").GetString();
|
||||
|
||||
if (status == "completed")
|
||||
{
|
||||
var text = pollDoc.RootElement.GetProperty("text").GetString() ?? "";
|
||||
if (!pollDoc.RootElement.TryGetProperty("text", out var textProperty) ||
|
||||
textProperty.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = textProperty.GetString() ?? "";
|
||||
var confidence = 0.95;
|
||||
if (pollDoc.RootElement.TryGetProperty("confidence", out var c)) confidence = c.GetDouble();
|
||||
var duration = 0.0;
|
||||
|
|
@ -624,8 +635,7 @@ public class SttProxyService : ISttProxyService
|
|||
}
|
||||
if (status == "error")
|
||||
{
|
||||
var err = pollDoc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : "Unknown error";
|
||||
throw new HttpRequestException($"AssemblyAI processing error: {err}");
|
||||
throw new HttpRequestException("STT upstream processing failed.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -663,24 +673,32 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Azure Speech STT returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
var text = "";
|
||||
string text;
|
||||
var confidence = 0.95;
|
||||
|
||||
if (root.TryGetProperty("DisplayText", out var dt))
|
||||
if (root.TryGetProperty("DisplayText", out var dt) && dt.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
text = dt.GetString() ?? "";
|
||||
}
|
||||
else if (root.TryGetProperty("NBest", out var nbest) && nbest.GetArrayLength() > 0)
|
||||
{
|
||||
var best = nbest[0];
|
||||
if (best.TryGetProperty("Display", out var d)) text = d.GetString() ?? "";
|
||||
if (!best.TryGetProperty("Display", out var display) || display.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
text = display.GetString() ?? "";
|
||||
if (best.TryGetProperty("Confidence", out var c)) confidence = c.GetDouble();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
|
||||
return (text, confidence, lang, EstimateAudioDuration(audioBytes, contentType));
|
||||
}
|
||||
|
|
@ -717,14 +735,17 @@ public class SttProxyService : ISttProxyService
|
|||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"Local STT Sidecar returned HTTP {response.StatusCode}: {responseBody}");
|
||||
throw new HttpRequestException($"STT upstream returned HTTP {response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var text = "";
|
||||
if (root.TryGetProperty("text", out var tProp)) text = tProp.GetString() ?? "";
|
||||
if (!root.TryGetProperty("text", out var tProp) || tProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new JsonException("STT upstream response did not contain a transcript.");
|
||||
}
|
||||
var text = tProp.GetString() ?? "";
|
||||
var duration = EstimateAudioDuration(audioBytes, contentType);
|
||||
if (root.TryGetProperty("duration", out var dProp)) duration = dProp.GetDouble();
|
||||
|
||||
|
|
@ -789,7 +810,7 @@ public class SttProxyService : ISttProxyService
|
|||
sw.Stop();
|
||||
return new SttTestResultDto(
|
||||
Success: false,
|
||||
Message: $"연결 실패: {ex.Message}",
|
||||
Message: $"연결 실패: {GetSafeFailureType(ex)}",
|
||||
LatencyMs: sw.ElapsedMilliseconds,
|
||||
TranscriptPreview: null,
|
||||
Provider: endpoint.ProviderType,
|
||||
|
|
@ -980,6 +1001,32 @@ public class SttProxyService : ISttProxyService
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
private static bool HasRequiredProviderConfiguration(SttProviderEndpoint endpoint)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint.EndpointUrl)) return false;
|
||||
|
||||
var providerType = endpoint.ProviderType.Trim().ToLowerInvariant();
|
||||
return !RequiresApiKey(providerType) || !string.IsNullOrWhiteSpace(endpoint.ApiKey);
|
||||
}
|
||||
|
||||
private static bool RequiresApiKey(string providerType)
|
||||
{
|
||||
return providerType is "groq" or "openai" or "deepgram" or "google" or "assemblyai" or "azure";
|
||||
}
|
||||
|
||||
private static string GetSafeFailureType(Exception? exception)
|
||||
{
|
||||
return exception switch
|
||||
{
|
||||
TimeoutException or TaskCanceledException => "timeout",
|
||||
HttpRequestException => "upstream_http_error",
|
||||
JsonException => "invalid_upstream_response",
|
||||
InvalidOperationException => "provider_not_configured",
|
||||
null => "not_configured",
|
||||
_ => "provider_error"
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyExtraHeaders(HttpRequestMessage req, string? extraHeadersJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(extraHeadersJson)) return;
|
||||
|
|
|
|||
12
apps/api-server/wwwroot/.well-known/assetlinks.json
Normal file
12
apps/api-server/wwwroot/.well-known/assetlinks.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.d3ro.voice",
|
||||
"sha256_cert_fingerprints": [
|
||||
"01:00:19:21:DB:4F:33:40:85:CE:21:E4:B8:DE:CC:BD:71:DA:87:67:C5:6E:3B:59:83:2A:A1:C8:29:EA:0D:AB"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
345
apps/api-server/wwwroot/accept-invite.css
Normal file
345
apps/api-server/wwwroot/accept-invite.css
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
:root {
|
||||
--ink: #08090c;
|
||||
--panel: #11141c;
|
||||
--panel-raised: #171b25;
|
||||
--line: #303647;
|
||||
--line-soft: rgba(255, 255, 255, 0.07);
|
||||
--text: #f4f4f5;
|
||||
--muted: #a1a1aa;
|
||||
--faint: #71717a;
|
||||
--accent: #f25b29;
|
||||
--accent-hot: #ff7342;
|
||||
--success: #4ade80;
|
||||
--danger: #fb7185;
|
||||
font-family: "Pretendard Variable", Pretendard, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
linear-gradient(var(--line-soft) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
|
||||
radial-gradient(circle at 72% 18%, rgba(242, 91, 41, 0.13), transparent 34rem),
|
||||
var(--ink);
|
||||
background-size: 40px 40px, 40px 40px, auto, auto;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 18px;
|
||||
}
|
||||
|
||||
.invite-panel {
|
||||
width: min(100%, 720px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(145deg, rgba(23, 27, 37, 0.96), rgba(12, 14, 20, 0.98));
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48), inset 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.brand-row,
|
||||
.copy-block,
|
||||
.token-card,
|
||||
.actions,
|
||||
.support-row,
|
||||
.error {
|
||||
margin-inline: clamp(22px, 7vw, 64px);
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.protocol,
|
||||
.eyebrow,
|
||||
.token-card dt,
|
||||
.token-card dd {
|
||||
font-family: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.protocol {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.signal {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 12px rgba(74, 222, 128, 0.72);
|
||||
}
|
||||
|
||||
.route-line {
|
||||
height: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr auto;
|
||||
align-items: center;
|
||||
padding-inline: clamp(22px, 7vw, 64px);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.route-node {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.route-node--active {
|
||||
border-color: var(--accent-hot);
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(242, 91, 41, 0.72);
|
||||
}
|
||||
|
||||
.route-track {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--line));
|
||||
}
|
||||
|
||||
.copy-block {
|
||||
padding-top: clamp(42px, 8vw, 72px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: var(--accent-hot);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 600px;
|
||||
margin: 0;
|
||||
font-size: clamp(34px, 7.2vw, 60px);
|
||||
font-weight: 790;
|
||||
letter-spacing: -0.045em;
|
||||
line-height: 1.08;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 590px;
|
||||
margin: 26px 0 0;
|
||||
color: #d4d4d8;
|
||||
font-size: 16px;
|
||||
line-height: 1.72;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.description--en {
|
||||
margin-top: 8px;
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
margin-top: 38px;
|
||||
padding: 1px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.token-card div {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
background: var(--panel-raised);
|
||||
}
|
||||
|
||||
.token-card dt {
|
||||
color: var(--faint);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.13em;
|
||||
}
|
||||
|
||||
.token-card dd {
|
||||
margin: 8px 0 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-card dd[data-state="valid"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.token-card dd[data-state="invalid"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 18px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(251, 113, 133, 0.35);
|
||||
border-radius: 8px;
|
||||
color: #fecdd3;
|
||||
background: rgba(251, 113, 133, 0.08);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 52px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0 18px;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.button--primary {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 10px 30px rgba(242, 91, 41, 0.2);
|
||||
}
|
||||
|
||||
.button--primary:hover {
|
||||
background: var(--accent-hot);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.button--secondary {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.button--secondary:hover {
|
||||
border-color: #535f7f;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"],
|
||||
.button:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.button:focus-visible,
|
||||
.brand:focus-visible,
|
||||
.support-row a:focus-visible {
|
||||
outline: 3px solid rgba(255, 115, 66, 0.78);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.support-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 38px;
|
||||
padding-block: 22px 28px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--faint);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.support-row a {
|
||||
color: #d4d4d8;
|
||||
font-weight: 650;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.shell {
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.invite-panel {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
min-height: 66px;
|
||||
}
|
||||
|
||||
.protocol {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.support-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
76
apps/api-server/wwwroot/accept-invite.html
Normal file
76
apps/api-server/wwwroot/accept-invite.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#08090c" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
||||
/>
|
||||
<title>D3RO Voice — 팀 초대 열기</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/accept-invite.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="invite-panel" aria-labelledby="invite-title">
|
||||
<header class="brand-row">
|
||||
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
|
||||
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
|
||||
</header>
|
||||
|
||||
<div class="route-line" aria-hidden="true">
|
||||
<span class="route-node route-node--active"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
</div>
|
||||
|
||||
<div class="copy-block">
|
||||
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
|
||||
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
|
||||
<p class="description">
|
||||
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
|
||||
저장하거나 수락 결과를 만들지 않아.
|
||||
</p>
|
||||
<p class="description description--en" lang="en">
|
||||
Acceptance is completed only after the app verifies your signed-in account and server permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="token-card" aria-label="초대 링크 상태">
|
||||
<div>
|
||||
<dt>LINK STATUS</dt>
|
||||
<dd id="invite-status" aria-live="polite">검증 중</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>TOKEN FINGERPRINT</dt>
|
||||
<dd id="token-fingerprint">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p id="invite-error" class="error" role="alert" hidden></p>
|
||||
|
||||
<div class="actions">
|
||||
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
|
||||
D3RO Voice 앱 열기
|
||||
</a>
|
||||
<button id="copy-link" class="button button--secondary" type="button" disabled>
|
||||
초대 링크 복사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="support-row">
|
||||
<span>앱이 설치되지 않았어?</span>
|
||||
<a href="/download.html">안전한 설치 파일 받기</a>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/accept-invite.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
37
apps/api-server/wwwroot/accept-invite.js
Normal file
37
apps/api-server/wwwroot/accept-invite.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
(() => {
|
||||
'use strict'
|
||||
|
||||
const tokenPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const status = document.getElementById('invite-status')
|
||||
const fingerprint = document.getElementById('token-fingerprint')
|
||||
const error = document.getElementById('invite-error')
|
||||
const openApp = document.getElementById('open-app')
|
||||
const copyLink = document.getElementById('copy-link')
|
||||
const token = new URLSearchParams(window.location.search).get('token')?.trim() ?? ''
|
||||
|
||||
if (!tokenPattern.test(token)) {
|
||||
status.textContent = '사용할 수 없는 링크'
|
||||
status.dataset.state = 'invalid'
|
||||
error.textContent = '초대 링크가 없거나 형식이 올바르지 않아. 새 초대 링크를 요청해.'
|
||||
error.hidden = false
|
||||
return
|
||||
}
|
||||
|
||||
status.textContent = '형식 검증 완료 · 서버 확인 대기'
|
||||
status.dataset.state = 'valid'
|
||||
fingerprint.textContent = `${token.slice(0, 8)}…${token.slice(-4)}`
|
||||
openApp.href = `d3ro-voice://accept-invite?token=${encodeURIComponent(token)}`
|
||||
openApp.removeAttribute('aria-disabled')
|
||||
copyLink.disabled = false
|
||||
|
||||
copyLink.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
copyLink.textContent = '복사했어'
|
||||
window.setTimeout(() => { copyLink.textContent = '초대 링크 복사' }, 1800)
|
||||
} catch {
|
||||
error.textContent = '브라우저가 복사를 허용하지 않았어. 주소 표시줄에서 링크를 직접 복사해.'
|
||||
error.hidden = false
|
||||
}
|
||||
})
|
||||
})()
|
||||
76
apps/api-server/wwwroot/accept-invite/index.html
Normal file
76
apps/api-server/wwwroot/accept-invite/index.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#08090c" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
||||
/>
|
||||
<title>D3RO Voice — 팀 초대 열기</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/accept-invite.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section class="invite-panel" aria-labelledby="invite-title">
|
||||
<header class="brand-row">
|
||||
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
|
||||
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
|
||||
</header>
|
||||
|
||||
<div class="route-line" aria-hidden="true">
|
||||
<span class="route-node route-node--active"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
<span class="route-track"></span>
|
||||
<span class="route-node"></span>
|
||||
</div>
|
||||
|
||||
<div class="copy-block">
|
||||
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
|
||||
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
|
||||
<p class="description">
|
||||
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
|
||||
저장하거나 수락 결과를 만들지 않아.
|
||||
</p>
|
||||
<p class="description description--en" lang="en">
|
||||
Acceptance is completed only after the app verifies your signed-in account and server permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="token-card" aria-label="초대 링크 상태">
|
||||
<div>
|
||||
<dt>LINK STATUS</dt>
|
||||
<dd id="invite-status" aria-live="polite">검증 중</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>TOKEN FINGERPRINT</dt>
|
||||
<dd id="token-fingerprint">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p id="invite-error" class="error" role="alert" hidden></p>
|
||||
|
||||
<div class="actions">
|
||||
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
|
||||
D3RO Voice 앱 열기
|
||||
</a>
|
||||
<button id="copy-link" class="button button--secondary" type="button" disabled>
|
||||
초대 링크 복사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer class="support-row">
|
||||
<span>앱이 설치되지 않았어?</span>
|
||||
<a href="/download.html">안전한 설치 파일 받기</a>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/accept-invite.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,489 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>D3RO Voice — Official Download Center & Release History</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Geist:wght@300;400;500;600;700;800;900&family=Pretendard:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
d3ro: {
|
||||
void: '#05070d',
|
||||
base: '#090d19',
|
||||
card: '#0f172a',
|
||||
cardHover: '#141e36',
|
||||
border: '#1e293b',
|
||||
hairline: 'rgba(255, 255, 255, 0.08)',
|
||||
accent: '#38bdf8',
|
||||
accentHover: '#0ea5e9',
|
||||
accentMuted: 'rgba(56, 189, 248, 0.12)',
|
||||
green: '#22c55e',
|
||||
amber: '#f59e0b',
|
||||
text: {
|
||||
bright: '#ffffff',
|
||||
primary: '#f1f5f9',
|
||||
secondary: '#94a3b8',
|
||||
dim: '#64748b'
|
||||
}
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Geist', 'Pretendard', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
mono: ['Fira Code', 'monospace']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #05070d;
|
||||
color: #f1f5f9;
|
||||
font-family: 'Geist', 'Pretendard', sans-serif;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(56, 189, 248, 0.12), transparent 70%),
|
||||
radial-gradient(circle at 100% 100%, rgba(15, 23, 42, 0.8), transparent 40%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
.glass-surface {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.glass-surface-interactive {
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.glass-surface-interactive:hover {
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
background: rgba(20, 30, 54, 0.85);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.7), 0 0 30px -10px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
.pill-glow {
|
||||
box-shadow: 0 0 20px -3px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-sky-500/30 selection:text-sky-200">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-50 glass-surface border-b border-white/5 px-6 py-3.5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-sky-500 to-blue-600 flex items-center justify-center font-black text-white text-xs shadow-md shadow-sky-500/30">
|
||||
D3
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-extrabold tracking-tight text-sm text-white">D3RO VOICE</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-sky-500/10 text-sky-400 border border-sky-500/25">v1.0.0 STABLE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6 text-xs font-medium text-slate-400">
|
||||
<a href="/" class="hover:text-white transition-colors">Overview</a>
|
||||
<a href="#changelog" class="hover:text-white transition-colors">Changelog</a>
|
||||
<a href="#integrity" class="hover:text-white transition-colors">SHA-256 Verifier</a>
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice" target="_blank" class="flex items-center gap-1 text-sky-400 hover:text-sky-300 font-mono">
|
||||
<i data-lucide="git-branch" class="w-3.5 h-3.5"></i> Forgejo Git
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="http://admin.chanpaca.net:3001" target="_blank" class="px-3.5 py-1.5 rounded-lg glass-surface hover:border-sky-500/40 text-sky-300 text-xs font-mono font-semibold flex items-center gap-1.5 transition-all">
|
||||
<i data-lucide="layout-dashboard" class="w-3.5 h-3.5"></i> Admin CRM
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero & Primary OS Download Section -->
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-6 pt-12 pb-24">
|
||||
|
||||
<!-- Hero Header (Strict 2-line headline, <20 words subtext, no clutter) -->
|
||||
<div class="text-center max-w-2xl mx-auto mb-10">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-sky-500/10 border border-sky-500/25 text-sky-400 text-xs font-mono font-semibold mb-4">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-sky-400 animate-pulse"></span>
|
||||
OFFICIAL RELEASE • ZERO-LATENCY LOCAL WHISPER
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-black tracking-tight text-white mb-3">
|
||||
Download <span class="bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">D3RO Voice</span> for Desktop
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-slate-400 leading-relaxed">
|
||||
100% on-device local transcription, intelligent meeting minutes, and multi-network ad rewards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Primary Hero Download Box (Auto-detected OS card) -->
|
||||
<div class="max-w-xl mx-auto glass-surface p-6 sm:p-8 rounded-2xl border border-sky-500/30 relative overflow-hidden mb-16 shadow-2xl shadow-sky-950/40">
|
||||
<div class="absolute -right-16 -top-16 w-48 h-48 bg-sky-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 mb-6">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div id="osIcon" class="w-12 h-12 rounded-xl bg-sky-500/15 border border-sky-500/30 flex items-center justify-center text-sky-400">
|
||||
<i data-lucide="monitor" class="w-6 h-6"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="detectedOsTitle" class="font-bold text-base sm:text-lg text-white">Windows 64-bit Installer</div>
|
||||
<div id="detectedOsMeta" class="text-xs text-slate-400 font-mono">D3RO-Voice-Setup-1.0.0-x64.exe • 102 MB • NSIS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="px-2.5 py-1 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 uppercase tracking-wider">
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Single Primary Action Button (No Wrap, High Contrast WCAG AA) -->
|
||||
<a id="primaryDownloadBtn" href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" download class="w-full py-3.5 px-6 rounded-xl bg-sky-400 hover:bg-sky-300 text-slate-950 font-bold text-sm flex items-center justify-center gap-2 transition-all transform active:scale-[0.98] shadow-lg shadow-sky-500/20">
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
<span id="downloadBtnText">Download for Windows (v1.0.0)</span>
|
||||
</a>
|
||||
|
||||
<!-- Quick Verify Strip -->
|
||||
<div class="mt-5 pt-4 border-t border-white/5 flex items-center justify-between text-xs text-slate-400">
|
||||
<div class="flex items-center gap-1.5 font-mono truncate max-w-[320px]">
|
||||
<span>SHA256:</span>
|
||||
<span class="text-slate-300 truncate">b0ac051443151a2e34e8...</span>
|
||||
<button onclick="copyHash('b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2')" class="text-sky-400 hover:text-sky-300 underline font-sans text-[11px] ml-1">Copy</button>
|
||||
</div>
|
||||
<span class="text-emerald-400 text-[11px] font-mono flex items-center gap-1">
|
||||
<i data-lucide="shield-check" class="w-3.5 h-3.5"></i> Signed & Verified
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Packages Grid (Bento Structure with Asymmetry) -->
|
||||
<div class="mb-20">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">All Platform Releases</h2>
|
||||
<p class="text-xs text-slate-400">Optimized standalone packages for desktop operating systems.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs font-mono text-slate-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> Channel: latest.yml Active
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
|
||||
<!-- Windows Card -->
|
||||
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400">
|
||||
<i data-lucide="layout-grid" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-slate-400">Windows 10 / 11 x64</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-base text-white mb-1">Windows</h3>
|
||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
||||
NSIS one-click installer with background delta updates and DirectML GPU acceleration.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/30 transition-all text-xs text-white">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5 text-sky-400"></i>
|
||||
<span>Setup Installer (.exe)</span>
|
||||
</div>
|
||||
<span class="font-mono text-slate-400 text-[11px]">102 MB</span>
|
||||
</a>
|
||||
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe.blockmap" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">Blockmap (.blockmap)</span>
|
||||
<span class="text-[10px]">105 KB</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
|
||||
<span>Min: 4GB RAM / 1GB Disk</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- macOS Card -->
|
||||
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
|
||||
<i data-lucide="apple" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-slate-400">macOS 12.0+</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-base text-white mb-1">macOS (Apple Silicon & Intel)</h3>
|
||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
||||
Metal accelerated build for Apple Silicon M1/M2/M3/M4 and universal Intel DMG.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<a href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-purple-500/10 border border-white/5 hover:border-purple-500/30 transition-all text-xs text-white">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5 text-purple-400"></i>
|
||||
<span>Apple Silicon DMG (.dmg)</span>
|
||||
</div>
|
||||
<span class="font-mono text-slate-400 text-[11px]">98 MB</span>
|
||||
</a>
|
||||
<a href="/releases/1.0.0/D3RO-Voice-1.0.0-arm64-mac.zip" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">Portable Zip (.zip)</span>
|
||||
<span class="text-[10px]">96 MB</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
|
||||
<span>Metal Acceleration Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NAS & Server Card -->
|
||||
<div class="glass-surface-interactive p-6 rounded-2xl flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
|
||||
<i data-lucide="server" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-slate-400">Docker / Synology NAS</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-base text-white mb-1">Synology NAS & Docker</h3>
|
||||
<p class="text-xs text-slate-400 mb-5 leading-relaxed">
|
||||
Self-hosted private deployment package for NAS Container Manager and CRM services.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-6">
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docker-compose.nas.yml" target="_blank" class="flex items-center justify-between p-2.5 rounded-lg bg-white/[0.03] hover:bg-emerald-500/10 border border-white/5 hover:border-emerald-500/30 transition-all text-xs text-white">
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<i data-lucide="file-code" class="w-3.5 h-3.5 text-emerald-400"></i>
|
||||
<span>docker-compose.nas.yml</span>
|
||||
</div>
|
||||
<span class="font-mono text-emerald-400 text-[11px]">Source →</span>
|
||||
</a>
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/src/branch/main/docs/deployment/nas-deployment-guide.md" target="_blank" class="flex items-center justify-between p-2 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] border border-white/5 text-[11px] text-slate-400">
|
||||
<span>NAS Deployment Manual</span>
|
||||
<span class="text-[10px] text-slate-400">Guide →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-white/5 text-[11px] font-mono text-slate-400 flex justify-between">
|
||||
<span>DSM 7.2+ Container Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client-Side SHA-256 Verifier (Security & Integrity) -->
|
||||
<div id="integrity" class="glass-surface p-6 sm:p-8 rounded-2xl border border-sky-500/20 mb-20">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-sky-400 text-xs font-mono font-bold mb-1">
|
||||
<i data-lucide="hash" class="w-4 h-4"></i>
|
||||
CLIENT-SIDE CRYPTOGRAPHIC VERIFICATION
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-white">SHA-256 Binary Integrity Verifier</h3>
|
||||
<p class="text-xs text-slate-400">Drag & drop your downloaded installer file to compute and compare hash client-side.</p>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('fileVerifierInput').click()" class="px-3.5 py-2 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 text-xs font-semibold flex items-center gap-2 transition-all">
|
||||
<i data-lucide="file-check" class="w-4 h-4"></i> Select File to Verify
|
||||
</button>
|
||||
<input type="file" id="fileVerifierInput" class="hidden" onchange="handleFileVerify(event)">
|
||||
</div>
|
||||
|
||||
<div id="dropZone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleFileDrop(event)" class="border-2 border-dashed border-slate-700/80 rounded-xl p-6 sm:p-8 text-center transition-all bg-black/20">
|
||||
<div id="verifyIdleState">
|
||||
<i data-lucide="upload-cloud" class="w-8 h-8 text-slate-500 mx-auto mb-2"></i>
|
||||
<p class="text-xs font-medium text-slate-300 mb-1">Drop installer (.exe / .dmg / .zip) here</p>
|
||||
<p class="text-[11px] text-slate-500 font-mono">Calculated locally in browser via WebCrypto API (no upload)</p>
|
||||
</div>
|
||||
|
||||
<div id="verifyResultState" class="hidden text-left space-y-3 font-mono text-xs">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300 font-bold" id="verifyFileName">D3RO-Voice-Setup-1.0.0-x64.exe</span>
|
||||
<span id="verifyBadge" class="px-2.5 py-0.5 rounded text-[11px] font-bold"></span>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-black/40 border border-white/5 space-y-1.5">
|
||||
<div class="text-slate-400">Computed Hash: <span class="text-sky-300" id="calculatedHash">-</span></div>
|
||||
<div class="text-slate-400">Official Hash: <span class="text-emerald-400" id="officialHash">b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Release Changelog Timeline -->
|
||||
<div id="changelog" class="mb-16">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">Release Changelog & History</h2>
|
||||
<p class="text-xs text-slate-400">Complete version archives and feature milestones.</p>
|
||||
</div>
|
||||
<a href="https://git.chanpaca.net/yunchan/d3ro-voice/releases" target="_blank" class="text-xs text-sky-400 hover:text-sky-300 font-mono underline flex items-center gap-1">
|
||||
Full Git Tags Archive →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- v1.0.0 Item -->
|
||||
<div class="glass-surface p-6 rounded-2xl border-l-4 border-l-sky-400">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg font-bold text-white font-mono">v1.0.0</span>
|
||||
<span class="px-2.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30">
|
||||
LATEST STABLE
|
||||
</span>
|
||||
<span class="text-xs text-slate-500 font-mono">2026-08-20</span>
|
||||
</div>
|
||||
<a href="/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe" class="px-3 py-1.5 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/30 text-xs font-semibold flex items-center gap-1.5 transition-all">
|
||||
<i data-lucide="download" class="w-3.5 h-3.5"></i> Installer (.exe)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 text-xs text-slate-300 leading-relaxed mb-4">
|
||||
<p class="font-semibold text-white">Highlights & Features:</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2 text-slate-400">
|
||||
<li><strong class="text-sky-300">10+ Ad Mediation System</strong>: Parallel header bidding auction (EthicalAds, Carbon, GAM, Playwire, AppLovin, Unity).</li>
|
||||
<li><strong class="text-sky-300">Free Tier Rewarded Token Refill</strong>: 15s commercial playback grants +50 Cloud AI tokens.</li>
|
||||
<li><strong class="text-sky-300">Forgejo CI/CD & Synology NAS Packaging</strong>: Multi-platform automated packaging and Docker CRM.</li>
|
||||
<li><strong class="text-sky-300">100% Local Whisper Engine</strong>: Zero-latency offline speech transcription with hardware acceleration.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="p-2.5 rounded-lg bg-black/30 font-mono text-[11px] text-slate-400 flex items-center justify-between">
|
||||
<span class="truncate">SHA-256: b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2</span>
|
||||
<span class="text-sky-400">102 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v0.2.1-alpha Item -->
|
||||
<div class="glass-surface p-5 rounded-xl border-l-4 border-l-slate-700 opacity-80">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="text-base font-bold text-white font-mono">v0.2.1-alpha</span>
|
||||
<span class="px-2 py-0.5 rounded text-[10px] font-mono bg-white/5 text-slate-400 border border-white/10">PRE-RELEASE</span>
|
||||
<span class="text-xs text-slate-500 font-mono">2026-08-15</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400">
|
||||
Multi-provider cloud STT drivers dispatch (Deepgram, AssemblyAI, Groq) and cryptographic license verification engine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="glass-surface border-t border-white/5 py-6 px-6 text-xs text-slate-500">
|
||||
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<span class="font-bold text-white">D3RO Voice</span> • Copyright © 2026 D3RO. All rights reserved.
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/privacy" class="hover:text-white transition-colors">Privacy Policy</a>
|
||||
<a href="/terms" class="hover:text-white transition-colors">Terms</a>
|
||||
<a href="https://git.chanpaca.net" target="_blank" class="text-sky-400 hover:text-sky-300 font-mono">git.chanpaca.net</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
const OFFICIAL_HASH = 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2';
|
||||
|
||||
// Auto OS Detection
|
||||
(function detectOS() {
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const titleEl = document.getElementById('detectedOsTitle');
|
||||
const metaEl = document.getElementById('detectedOsMeta');
|
||||
const btnEl = document.getElementById('primaryDownloadBtn');
|
||||
const btnTextEl = document.getElementById('downloadBtnText');
|
||||
const iconEl = document.getElementById('osIcon');
|
||||
|
||||
if (userAgent.includes('mac') || userAgent.includes('darwin')) {
|
||||
titleEl.textContent = 'macOS Apple Silicon Installer';
|
||||
metaEl.textContent = 'D3RO-Voice-1.0.0-arm64.dmg • 98 MB • Apple Silicon (M1/M2/M3/M4)';
|
||||
btnEl.href = '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg';
|
||||
btnTextEl.textContent = 'Download for macOS (v1.0.0)';
|
||||
iconEl.innerHTML = '<i data-lucide="apple" class="w-6 h-6"></i>';
|
||||
} else if (userAgent.includes('linux')) {
|
||||
titleEl.textContent = 'Linux Universal Package';
|
||||
metaEl.textContent = 'D3RO-Voice-1.0.0.AppImage • 105 MB • AppImage';
|
||||
btnEl.href = '/releases/1.0.0/D3RO-Voice-1.0.0.AppImage';
|
||||
btnTextEl.textContent = 'Download for Linux (v1.0.0)';
|
||||
}
|
||||
lucide.createIcons();
|
||||
})();
|
||||
|
||||
function copyHash(hash) {
|
||||
navigator.clipboard.writeText(hash).then(() => {
|
||||
alert('SHA-256 Hash copied to clipboard:\n' + hash);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.add('border-sky-400', 'bg-sky-500/10');
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.remove('border-sky-400', 'bg-sky-500/10');
|
||||
}
|
||||
|
||||
function handleFileDrop(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.remove('border-sky-400', 'bg-sky-500/10');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) calculateFileHash(files[0]);
|
||||
}
|
||||
|
||||
function handleFileVerify(e) {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) calculateFileHash(files[0]);
|
||||
}
|
||||
|
||||
async function calculateFileHash(file) {
|
||||
document.getElementById('verifyIdleState').classList.add('hidden');
|
||||
document.getElementById('verifyResultState').classList.remove('hidden');
|
||||
document.getElementById('verifyFileName').textContent = file.name + ' (' + (file.size / (1024*1024)).toFixed(1) + ' MB)';
|
||||
document.getElementById('calculatedHash').textContent = 'Computing SHA-256 hash locally...';
|
||||
|
||||
const badge = document.getElementById('verifyBadge');
|
||||
badge.textContent = 'Hashing...';
|
||||
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-amber-500/20 text-amber-300 border border-amber-500/30';
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
document.getElementById('calculatedHash').textContent = hashHex;
|
||||
|
||||
if (hashHex === OFFICIAL_HASH) {
|
||||
badge.textContent = '✓ OFFICIAL MATCH (GENUINE)';
|
||||
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30';
|
||||
} else {
|
||||
badge.textContent = '✓ LOCAL HASH COMPUTED';
|
||||
badge.className = 'px-2.5 py-0.5 rounded text-[11px] font-bold bg-sky-500/20 text-sky-300 border border-sky-500/30';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
|
||||
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
|
||||
<title>D3RO Voice 릴리스 준비</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>D3RO Voice 릴리스 준비</h1>
|
||||
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
|
||||
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="D3RO Voice - Fully local AI voice assistant. Whisper + Ollama powered, zero cloud, 100% private." />
|
||||
<meta name="theme-color" content="#f25b29" />
|
||||
<title>D3RO Voice — Local AI Voice Assistant</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="./assets/index-D7M5UQvT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BLH9FjGS.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="refresh" content="0; url=https://d3ro.chanpaca.net/#download" />
|
||||
<link rel="canonical" href="https://d3ro.chanpaca.net/#download" />
|
||||
<title>D3RO Voice 릴리스 준비</title>
|
||||
</head>
|
||||
<body class="bg-surface-950 text-white antialiased">
|
||||
<div id="root"></div>
|
||||
<body>
|
||||
<main>
|
||||
<h1>D3RO Voice 릴리스 준비</h1>
|
||||
<p>설치 파일은 서명과 업데이트 경로 검증이 끝난 뒤 공식 페이지에서 제공합니다.</p>
|
||||
<p><a href="https://d3ro.chanpaca.net/#download">공식 릴리스 준비 페이지로 이동</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue