chore: remove duplicate copies of the site, download center and installers (WS-C)

The landing page, download pages, invite page, assetlinks and installers
existed in two or three places; only site/ and the Forgejo feed are served.

- apps/api-server/wwwroot: delete the stale site build, download/invite
  pages, .well-known copy, legacy static admin and 1.0.0 binaries. The API
  no longer serves static files (UseStaticFiles/fallbacks and the apk/zip
  blocker removed); the Next admin is the only admin UI.
- Delete 19 tracked installers/packages (~568 MiB) under site/public/releases,
  apps/web/public/releases and wwwroot/releases; .gitignore blocks them.
- apps/web: delete the download/releases pages, desktop-release.ts, the
  download.html and assetlinks copies, and the accept-invite page (invites
  are only issued to the site's /accept-invite/). e2e specs call the /app
  base path and check the /download redirect instead.
- scripts: delete the retired release/NAS site scripts, drop the web target
  from sync-version, and check assetlinks in site/public only.
- Delete the unused Dockerfile.admin (apps/admin/Dockerfile is used).

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-5 and W3-6.
This commit is contained in:
Yun Chan 2026-09-26 15:48:52 +09:00
parent b6fe588a7c
commit cd9d199dbf
53 changed files with 43 additions and 3278 deletions

View file

@ -7,14 +7,6 @@
<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.10" />

View file

@ -393,65 +393,9 @@ if (app.Environment.IsDevelopment())
}
app.UseCors("ConfiguredOrigins");
app.Use(async (context, next) =>
{
var isInvitePage =
context.Request.Path.StartsWithSegments("/accept-invite")
|| context.Request.Path.Equals("/accept-invite.html");
if (isInvitePage)
{
context.Response.OnStarting(() =>
{
// The invite token lives in the query string. Do not cache the page and
// prevent CDN HTML transforms (including analytics script injection).
context.Response.Headers["Cache-Control"] = "no-store, no-transform";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " +
"form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " +
"object-src 'none'; script-src 'self'; style-src 'self'";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
return Task.CompletedTask;
});
}
if (context.Request.Path.Equals("/accept-invite"))
{
context.Response.StatusCode = StatusCodes.Status308PermanentRedirect;
context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}";
return;
}
await next();
});
app.UseDefaultFiles();
// Historical mobile binaries remain in the checkout for forensics only. They
// are not official releases and must never be reachable through StaticFiles.
app.Use(async (context, next) =>
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var fileName = Path.GetFileName(requestPath);
var legacyMarketingAsset = requestPath.Equals("/assets/index-D7M5UQvT.js", StringComparison.OrdinalIgnoreCase)
|| requestPath.Equals("/assets/index-JlYFxlAJ.js", StringComparison.OrdinalIgnoreCase);
var mobileReleasePath = requestPath.StartsWith("/releases/", StringComparison.OrdinalIgnoreCase)
&& (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".aab", StringComparison.OrdinalIgnoreCase)
|| (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
&& (fileName.Contains("android", StringComparison.OrdinalIgnoreCase)
|| fileName.Contains("signed", StringComparison.OrdinalIgnoreCase))));
if (mobileReleasePath || legacyMarketingAsset)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next();
});
app.UseStaticFiles();
// The API serves no static pages. Landing, download, legal, invite and
// assetlinks pages live only in site/ (Cloudflare Pages); the admin UI is
// apps/admin. Installers are published only through the Forgejo update feed.
app.UseRateLimiter();
app.UseAuthentication();
@ -480,10 +424,6 @@ app.MapGet("/api/health", () => Results.Ok(new
app.MapControllers();
app.MapFallbackToFile("/accept-invite", "accept-invite.html");
// Fallback to Admin BackOffice UI index.html
app.MapFallbackToFile("/admin/{*path}", "admin/index.html");
app.Run();
public partial class Program { }

View file

@ -1,12 +0,0 @@
[
{
"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

@ -1,345 +0,0 @@
: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

@ -1,76 +0,0 @@
<!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,37 +0,0 @@
(() => {
'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

@ -1,76 +0,0 @@
<!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,730 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>D3RO Voice — Admin BackOffice</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=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/lucide@latest/dist/umd/lucide.min.js"></script>
<style>
:root {
--bg-gradient: radial-gradient(circle at 50% 0%, #171b26 0%, #0b0d13 100%);
--card-bg: rgba(22, 27, 38, 0.7);
--card-border: rgba(255, 255, 255, 0.08);
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-glow: rgba(99, 102, 241, 0.35);
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg-gradient);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Layout Header */
header {
border-bottom: 1px solid var(--card-border);
backdrop-filter: blur(12px);
background: rgba(11, 13, 19, 0.8);
position: sticky;
top: 0;
z-index: 100;
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-family: 'Outfit', sans-serif;
font-weight: 700;
font-size: 20px;
letter-spacing: -0.5px;
}
.brand-badge {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
font-family: 'Inter', sans-serif;
font-weight: 600;
}
nav {
display: flex;
gap: 8px;
}
.nav-btn {
background: transparent;
border: none;
color: var(--text-muted);
padding: 8px 16px;
border-radius: 8px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.nav-btn:hover, .nav-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.nav-btn.active {
border: 1px solid var(--accent-glow);
color: var(--accent);
}
/* Main Container */
main {
flex: 1;
max-width: 1400px;
margin: 0 auto;
width: 100%;
padding: 32px 24px;
}
.page-section { display: none; }
.page-section.active { display: block; animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Metric Cards Grid */
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.card-title {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-value {
font-family: 'Outfit', sans-serif;
font-size: 32px;
font-weight: 700;
color: #fff;
}
.card-sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
/* Data Tables */
.table-container {
width: 100%;
overflow-x: auto;
margin-top: 16px;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 14px;
}
th {
padding: 14px 16px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
font-weight: 600;
border-bottom: 1px solid var(--card-border);
}
td {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: var(--text-main);
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.badge-success { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.badge-danger { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
.badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
.badge-primary { background: rgba(99, 102, 241, 0.15); color: var(--accent); }
/* Action Buttons */
.btn {
background: var(--accent);
color: #fff;
border: none;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.btn:hover { background: var(--accent-hover); box-shadow: 0 0 16px var(--accent-glow); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-danger { background: rgba(239, 68, 68, 0.2); color: var(--danger); border: 1px solid rgba(239, 68, 68, 0.4); }
.btn-danger:hover { background: var(--danger); color: #fff; }
/* Section Headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-title {
font-family: 'Outfit', sans-serif;
font-size: 22px;
font-weight: 700;
}
/* Modal Form */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-overlay.active { display: flex; }
.modal-box {
background: #131722;
border: 1px solid var(--card-border);
border-radius: 20px;
width: 100%;
max-width: 540px;
padding: 32px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 6px;
}
.form-control {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--card-border);
color: #fff;
padding: 10px 14px;
border-radius: 8px;
font-size: 14px;
outline: none;
}
.form-control:focus {
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="brand">
<i data-lucide="shield-check" style="color: var(--accent);"></i>
<span>D3RO VOICE</span>
<span class="brand-badge">BackOffice API Server</span>
</div>
<nav>
<button class="nav-btn active" onclick="showTab('dashboard')"><i data-lucide="layout-dashboard"></i> 대시보드</button>
<button class="nav-btn" onclick="showTab('users')"><i data-lucide="users"></i> 사용자 관리</button>
<button class="nav-btn" onclick="showTab('endpoints')"><i data-lucide="cpu"></i> 서비스 모델 리스트</button>
<button class="nav-btn" onclick="showTab('usage')"><i data-lucide="bar-chart-3"></i> 상세 Usage & 비용</button>
</nav>
</div>
</header>
<main>
<!-- 1. DASHBOARD -->
<section id="dashboard" class="page-section active">
<div class="section-header">
<h2 class="section-title">백엔드 서버 대시보드</h2>
<button class="btn btn-sm" onclick="loadStats()"><i data-lucide="refresh-cw"></i> 새로고침</button>
</div>
<div class="grid-4">
<div class="card">
<div class="card-title">서버 상태 & 업타임 <i data-lucide="activity" style="color: var(--success);"></i></div>
<div class="card-value" id="val-uptime">0s</div>
<div class="card-sub" id="val-health"><span class="badge badge-success">정상 작동 중</span></div>
</div>
<div class="card">
<div class="card-title">총 사용자 수 <i data-lucide="user-check" style="color: var(--accent);"></i></div>
<div class="card-value" id="val-users">0</div>
<div class="card-sub" id="val-active-today">오늘 접속: 0명</div>
</div>
<div class="card">
<div class="card-title">API 요청 처리량 <i data-lucide="zap" style="color: var(--warning);"></i></div>
<div class="card-value" id="val-requests">0</div>
<div class="card-sub">누적 AI API 호출 수</div>
</div>
<div class="card">
<div class="card-title">총 사용량 & 예상 비용 <i data-lucide="dollar-sign" style="color: var(--success);"></i></div>
<div class="card-value" id="val-cost">$0.0000</div>
<div class="card-sub">실시간 토큰 비용 계산</div>
</div>
</div>
<div class="card">
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">운영 에러 & 서비스 로그</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>에러 유형</th>
<th>메시지</th>
<th>엔드포인트</th>
<th>발생 일시</th>
</tr>
</thead>
<tbody id="tbody-errors">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">로그 데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 2. USERS -->
<section id="users" class="page-section">
<div class="section-header">
<h2 class="section-title">가입 사용자 목록</h2>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>이메일</th>
<th>권한</th>
<th>상태</th>
<th>가입 일시</th>
<th>최근 로그인</th>
</tr>
</thead>
<tbody id="tbody-users">
<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">사용자 목록을 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 3. MODEL ENDPOINTS -->
<section id="endpoints" class="page-section">
<div class="section-header">
<h2 class="section-title">서비스 제공 모델 & 엔드포인트 설정</h2>
<button class="btn" onclick="openModelModal()"><i data-lucide="plus"></i> 모델 엔드포인트 추가</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>모델 ID</th>
<th>모델 이름</th>
<th>제공자</th>
<th>엔드포인트 URL</th>
<th>입력 토큰 비용 ($/1k)</th>
<th>출력 토큰 비용 ($/1k)</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody id="tbody-endpoints">
<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">모델 엔드포인트 데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- 4. USAGE & COST -->
<section id="usage" class="page-section">
<div class="section-header">
<h2 class="section-title">상세 Usage & 비용 분석 (BE Home)</h2>
</div>
<div class="grid-4" style="margin-bottom: 24px;">
<div class="card">
<div class="card-title">총 프롬프트 토큰</div>
<div class="card-value" id="val-p-tokens">0</div>
</div>
<div class="card">
<div class="card-title">총 완성 토큰</div>
<div class="card-value" id="val-c-tokens">0</div>
</div>
<div class="card">
<div class="card-title">총 토큰 합계</div>
<div class="card-value" id="val-t-tokens">0</div>
</div>
<div class="card">
<div class="card-title">누적 과금 금액</div>
<div class="card-value" id="val-total-cost">$0.0000</div>
</div>
</div>
<div class="card" style="margin-bottom: 24px;">
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">사용자별 사용량 및 비용</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>사용자 ID</th>
<th>이메일</th>
<th>호출 횟수</th>
<th>사용 토큰</th>
<th>계산 비용 ($)</th>
</tr>
</thead>
<tbody id="tbody-user-usage">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용량 리포트를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-title" style="font-size: 16px; font-weight: 700; color: #fff;">모델 엔드포인트별 사용량</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>모델 ID</th>
<th>모델 이름</th>
<th>호출 횟수</th>
<th>총 토큰</th>
<th>계산 비용 ($)</th>
</tr>
</thead>
<tbody id="tbody-model-usage">
<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용량 리포트를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<!-- ADD / EDIT ENDPOINT MODAL -->
<div class="modal-overlay" id="modelModal">
<div class="modal-box">
<div class="modal-header">
<h3 style="font-family: 'Outfit', sans-serif; font-size: 20px;">서비스 모델 엔드포인트 추가</h3>
<button onclick="closeModelModal()" style="background:none; border:none; color:var(--text-muted); cursor:pointer;"><i data-lucide="x"></i></button>
</div>
<form id="endpointForm" onsubmit="handleSaveEndpoint(event)">
<div class="form-group">
<label>모델 ID (영문 식별자)</label>
<input type="text" id="m-id" class="form-control" placeholder="예: d3ro-custom-model" required>
</div>
<div class="form-group">
<label>모델 Display 이름</label>
<input type="text" id="m-name" class="form-control" placeholder="예: D3RO Premium Custom LLM" required>
</div>
<div class="form-row">
<div class="form-group">
<label>제공자 (Provider)</label>
<input type="text" id="m-provider" class="form-control" placeholder="OpenAI / Custom" required>
</div>
<div class="form-group">
<label>API Key (선택)</label>
<input type="password" id="m-key" class="form-control" placeholder="sk-...">
</div>
</div>
<div class="form-group">
<label>엔드포인트 URL</label>
<input type="url" id="m-url" class="form-control" placeholder="https://api.openai.com/v1/chat/completions" required>
</div>
<div class="form-row">
<div class="form-group">
<label>입력 토큰 비용 ($/1k)</label>
<input type="number" step="0.000001" id="m-cost-p" class="form-control" value="0.000150" required>
</div>
<div class="form-group">
<label>출력 토큰 비용 ($/1k)</label>
<input type="number" step="0.000001" id="m-cost-c" class="form-control" value="0.000600" required>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<button type="button" class="btn" style="background: transparent; border: 1px solid var(--card-border);" onclick="closeModelModal()">취소</button>
<button type="submit" class="btn"><i data-lucide="check"></i> 저장하기</button>
</div>
</form>
</div>
</div>
<script>
lucide.createIcons();
function showTab(tabId) {
document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.page-section').forEach(sec => sec.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById(tabId).classList.add('active');
if (tabId === 'dashboard') loadStats();
if (tabId === 'users') loadUsers();
if (tabId === 'endpoints') loadEndpoints();
if (tabId === 'usage') loadUsageReport();
}
async function loadStats() {
try {
const res = await fetch('/api/admin/stats');
if (!res.ok) return;
const data = await res.json();
document.getElementById('val-uptime').innerText = Math.floor(data.serverUptimeSeconds) + '초';
document.getElementById('val-users').innerText = data.totalUsers;
document.getElementById('val-active-today').innerText = '오늘 접속: ' + data.activeUsersToday + '명';
document.getElementById('val-requests').innerText = data.totalRequests;
document.getElementById('val-cost').innerText = '$' + data.totalCost.toFixed(4);
const tbody = document.getElementById('tbody-errors');
if (data.recentErrors.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--success);">에러 로그 없음 (시스템 정상)</td></tr>';
} else {
tbody.innerHTML = data.recentErrors.map(e => `
<tr>
<td>${e.id}</td>
<td><span class="badge badge-danger">${e.errorType}</span></td>
<td>${e.message}</td>
<td>${e.endpoint || '-'}</td>
<td>${new Date(e.createdAt).toLocaleString()}</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
async function loadUsers() {
try {
const res = await fetch('/api/admin/users');
if (!res.ok) return;
const users = await res.json();
const tbody = document.getElementById('tbody-users');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">가입된 사용자가 없습니다.</td></tr>';
} else {
tbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td style="font-weight: 600;">${u.email}</td>
<td><span class="badge ${u.role === 'Admin' ? 'badge-primary' : 'badge-warning'}">${u.role}</span></td>
<td><span class="badge ${u.isActive ? 'badge-success' : 'badge-danger'}">${u.isActive ? '활성' : '비활성'}</span></td>
<td>${new Date(u.createdAt).toLocaleString()}</td>
<td>${u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '미접속'}</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
async function loadEndpoints() {
try {
const res = await fetch('/api/admin/endpoints');
if (!res.ok) return;
const endpoints = await res.json();
const tbody = document.getElementById('tbody-endpoints');
if (endpoints.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">등록된 서비스 모델 엔드포인트가 없습니다.</td></tr>';
} else {
tbody.innerHTML = endpoints.map(e => `
<tr>
<td><code>${e.modelId}</code></td>
<td style="font-weight:600;">${e.modelName}</td>
<td><span class="badge badge-primary">${e.provider}</span></td>
<td style="font-size:12px; color:var(--text-muted);">${e.endpointUrl}</td>
<td>$${e.costPer1kPromptTokens.toFixed(6)}</td>
<td>$${e.costPer1kCompletionTokens.toFixed(6)}</td>
<td><span class="badge ${e.isActive ? 'badge-success' : 'badge-danger'}">${e.isActive ? '제공중' : '중지'}</span></td>
<td>
<button class="btn btn-sm btn-danger" onclick="deleteEndpoint(${e.id})">삭제</button>
</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
async function loadUsageReport() {
try {
const res = await fetch('/api/admin/usage');
if (!res.ok) return;
const report = await res.json();
document.getElementById('val-p-tokens').innerText = report.totalPromptTokens.toLocaleString();
document.getElementById('val-c-tokens').innerText = report.totalCompletionTokens.toLocaleString();
document.getElementById('val-t-tokens').innerText = (report.totalPromptTokens + report.totalCompletionTokens).toLocaleString();
document.getElementById('val-total-cost').innerText = '$' + report.totalCost.toFixed(4);
const tbodyUser = document.getElementById('tbody-user-usage');
if (report.userSummaries.length === 0) {
tbodyUser.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">사용 이력이 없습니다.</td></tr>';
} else {
tbodyUser.innerHTML = report.userSummaries.map(u => `
<tr>
<td>${u.userId}</td>
<td>${u.email}</td>
<td>${u.totalRequests}회</td>
<td>${u.totalTokens.toLocaleString()}</td>
<td style="font-weight:700; color:var(--success);">$${u.totalCost.toFixed(6)}</td>
</tr>
`).join('');
}
const tbodyModel = document.getElementById('tbody-model-usage');
if (report.modelSummaries.length === 0) {
tbodyModel.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--text-muted);">모델 사용 이력이 없습니다.</td></tr>';
} else {
tbodyModel.innerHTML = report.modelSummaries.map(m => `
<tr>
<td><code>${m.modelId}</code></td>
<td>${m.modelName}</td>
<td>${m.totalRequests}회</td>
<td>${m.totalTokens.toLocaleString()}</td>
<td style="font-weight:700; color:var(--success);">$${m.totalCost.toFixed(6)}</td>
</tr>
`).join('');
}
} catch (err) {
console.error(err);
}
}
function openModelModal() {
document.getElementById('modelModal').classList.add('active');
}
function closeModelModal() {
document.getElementById('modelModal').classList.remove('active');
}
async function handleSaveEndpoint(e) {
e.preventDefault();
const payload = {
modelId: document.getElementById('m-id').value,
modelName: document.getElementById('m-name').value,
provider: document.getElementById('m-provider').value,
apiKey: document.getElementById('m-key').value,
endpointUrl: document.getElementById('m-url').value,
costPer1kPromptTokens: parseFloat(document.getElementById('m-cost-p').value),
costPer1kCompletionTokens: parseFloat(document.getElementById('m-cost-c').value)
};
try {
const res = await fetch('/api/admin/endpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
closeModelModal();
loadEndpoints();
document.getElementById('endpointForm').reset();
} else {
const err = await res.json();
alert(err.message || '저장 실패');
}
} catch (err) {
alert('에러 발생: ' + err.message);
}
}
async function deleteEndpoint(id) {
if (!confirm('이 서비스 모델 엔드포인트를 삭제하시겠습니까?')) return;
try {
const res = await fetch('/api/admin/endpoints/' + id, { method: 'DELETE' });
if (res.ok) loadEndpoints();
} catch (err) {
alert('삭제 실패: ' + err.message);
}
}
// Initialize
loadStats();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,17 +0,0 @@
<!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,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#19191b"/>
<circle cx="32" cy="28" r="8" fill="none" stroke="#f25b29" stroke-width="3"/>
<rect x="29" y="36" width="6" height="10" rx="3" fill="#f25b29"/>
<path d="M20 32 Q20 44 32 44 Q44 44 44 32" fill="none" stroke="#f25b29" stroke-width="2.5" stroke-linecap="round"/>
<line x1="32" y1="44" x2="32" y2="52" stroke="#f25b29" stroke-width="2.5" stroke-linecap="round"/>
<line x1="26" y1="52" x2="38" y2="52" stroke="#f25b29" stroke-width="2.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 592 B

View file

@ -1,17 +0,0 @@
<!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,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'

View file

@ -1,8 +0,0 @@
version: 1.0.0
files:
- url: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
size: 102123822
path: D3RO-Voice-Setup-1.0.0-x64.exe
sha512: Ae4ZDaqL6mtkpyGsX9rna6WT3hhtChiEF4hl+Yx1CQvjCPwTq7LmhcBEm40TvRHOVtjQSASsCTs1wxVgSRo1Ow==
releaseDate: '2026-08-20T01:56:59.299Z'