feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -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 = "이메일과 비밀번호를 입력해주세요." });
}

View file

@ -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);

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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"]

View file

@ -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

View 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();
}
}

View file

@ -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;
}
}

View file

@ -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;

View 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"
]
}
}
]

View 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;
}
}

View 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>

View 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
}
})
})()

View 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>

View file

@ -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>

View file

@ -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>