Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Public build settings only; never put backend/API secrets in VITE_* variables.
VITE_API_BASE_URL=
# Public sitekey (never the secret). Empty disables the widget only in local dev.
# Local end-to-end tests may use Cloudflare's documented dummy sitekey.
VITE_TURNSTILE_SITE_KEY=
# Native Vite development; Compose overrides this to host.docker.internal.
FASTAPI_PROXY_TARGET=http://localhost:8000
10 changes: 10 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,21 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Require production Turnstile sitekey
env:
TURNSTILE_SITEKEY: ${{ vars.VITE_TURNSTILE_SITE_KEY }}
run: |
test -n "$TURNSTILE_SITEKEY"
case "$TURNSTILE_SITEKEY" in
[123]x000000*) echo 'Production must not use a dummy sitekey'; exit 1 ;;
esac
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
id: build
with:
context: .
target: runtime
build-args: |
VITE_TURNSTILE_SITE_KEY=${{ vars.VITE_TURNSTILE_SITE_KEY }}
platforms: linux/arm64
push: true
tags: |
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ RUN npm audit --audit-level=high --json > /tmp/audit.json 2>/dev/null || true; \
# Stage 4: Build the React application
FROM deps AS build

# Public widget identifier only. The secret stays in the backend runtime .env.
ARG VITE_TURNSTILE_SITE_KEY
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}

# Copy rest of sources
COPY . .

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ docker compose -f compose.dev.yaml up -d --build
localhost:5173에서 접속합니다. Docker의 Vite 프록시는 호스트의 백엔드 :8000을 사용합니다.
네이티브 실행은 `.env.example`을 참고하여 `npm ci`, `npm run dev`를 사용합니다.
`VITE_API_BASE_URL`은 비워 두어야 합니다. API 호출에 이미 `/api/v1/...` 전체 경로가 들어갑니다.
운영 빌드는 공개 `VITE_TURNSTILE_SITE_KEY`가 필요합니다. 계산 전에 사람 인증을 수행하고,
백엔드가 검증합니다. 위젯 비밀키는 백엔드 런타임 `.env`에서만 관리합니다.

계산 결과를 먼저 표시하고 주가·환율·벤치마크·뉴스는 네 API로 각각 조회·재시도합니다.
참고 자료의 조회·재시도·취소 흐름은 [API 데이터 흐름](docs/api-data-flow.md)을 참고합니다.
Expand Down
16 changes: 16 additions & 0 deletions docs/api-data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,19 @@ Docker 테스트는 네 자료 중 하나가 느리거나 실패한 경우, 병
배포용 FE·BE·격리 MySQL 브라우저 검사에서는 합성 시세·뉴스를 사용한다. 조회 네 개를
503으로 만든 뒤 하나씩 복구하고, 해당 GET만 증가하며 계산 POST 호출 수는 그대로인지
접근 로그로 확인한다. 운영 자격증명·운영 DB는 사용하지 않는다.

## 계산 전 사람 인증

운영 화면은 Cloudflare Turnstile 인증을 완료해야 계산 버튼이 활성화된다.
공개 sitekey는 GitHub Actions Variable `VITE_TURNSTILE_SITE_KEY` → Docker 빌드 인자
→ Vite 번들로 전달한다. 비밀키는 프론트엔드·GitHub에 넣지 않는다.

토큰은 POST `/api/v1/backtest`의 `X-Turnstile-Token` 헤더로만 보내며 요청 JSON은
변경하지 않는다. 백엔드 Siteverify 검증을 통과해야 계산이 시작된다. 403은 재인증,
503은 검증 불가/서버 혼잡 메시지로 표시한다. 성공·실패·취소 후에는 새 위젯을
렌더링해 일회용 토큰을 다시 받는다. 입력값 검증 실패는 토큰을 소비하지 않는다.
만료·위젯 오류 시 버튼을 잠그며, 로딩 실패는 인증 재시도 버튼을 제공한다.

아래 네 조회와 각 재시도는 토큰을 보내거나 계산을 재실행하지 않는다.
sitekey가 없는 운영 빌드는 계산 버튼을 잠근다. 위젯 없는 실행은 로컬 개발에서만
허용하며, 자동 통합 검증은 Cloudflare 공식 더미 키를 사용한다.
8 changes: 4 additions & 4 deletions nginx.prod.conf
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ server {
# index-*.js에서 confirmed: e=document.createElement("style") 후 cssText
# 대입). 앱에 nonce가 배선되어 있지 않아 style-src에서 'unsafe-inline'을
# 빼면 모달/드롭다운/셀렉트를 열 때마다 콘솔 에러와 함께 스크롤 잠금이
# 깨진다. script-src는 인라인 스크립트/eval이 전혀 없어 'self'만으로 충분.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always;
# 깨진다. Turnstile 공식 스크립트·iframe만 추가 허용한다. 인라인 스크립트/eval은 허용하지 않는다.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always;

# P3-13: gzip 압축. base nginx.conf(이미지 기본)는 gzip이 꺼져 있고 이 리포는
# 그 파일을 건드릴 수 없으므로 서버 레벨에서 켠다. text/html은 gzip 모듈이
Expand Down Expand Up @@ -67,7 +67,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
Expand All @@ -79,7 +79,7 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';" always;
add_header Content-Type text/plain;
return 200 'ok';
}
Expand Down
18 changes: 15 additions & 3 deletions src/features/backtest/components/PortfolioBacktestForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import CommissionForm from './CommissionForm';
import PortfolioForm from './PortfolioForm';
import { useBacktestForm } from '../hooks/useBacktestForm';
import { useFormValidation } from '../hooks/useFormValidation';
import { TurnstileVerification } from './TurnstileVerification';
import { Button } from '@/shared/ui/button';
import { FormSection } from '@/shared/components';
import {
Expand All @@ -20,14 +21,18 @@ import {
} from '@/shared/ui/dialog';

interface PortfolioBacktestFormProps {
onSubmit: (request: BacktestRequest) => Promise<unknown>;
onSubmit: (request: BacktestRequest, turnstileToken?: string) => Promise<unknown>;
loading?: boolean;
}

const PortfolioBacktestForm: React.FC<PortfolioBacktestFormProps> = ({ onSubmit, loading = false }) => {
const { state, actions } = useBacktestForm();
const { errors, validateForm } = useFormValidation();
const [showErrorModal, setShowErrorModal] = useState(false);
const [turnstileToken, setTurnstileToken] = useState('');
const [verificationAttempt, setVerificationAttempt] = useState(0);
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '';
const verificationRequired = import.meta.env.PROD || !!siteKey;

const generateStrategyParams = () => {
const strategyParams = state.strategy.strategyParams;
Expand All @@ -36,6 +41,7 @@ const PortfolioBacktestForm: React.FC<PortfolioBacktestFormProps> = ({ onSubmit,

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading || state.ui.isLoading || (verificationRequired && !turnstileToken)) return;
actions.setLoading(true);

const isFormValid = validateForm(state);
Expand Down Expand Up @@ -70,7 +76,7 @@ const PortfolioBacktestForm: React.FC<PortfolioBacktestFormProps> = ({ onSubmit,
rebalance_frequency: resolveRebalanceFrequency(
state.settings.rebalanceFrequency, strategy, state.portfolio.length
)
});
}, turnstileToken || undefined);
} catch (error) {
// 백엔드/네트워크 에러는 usePortfolioBacktest 훅이 extractErrorMessage로
// 실제 메시지를 추출해 페이지 레벨 Alert에 표시한다. 이 모달은 위쪽의
Expand All @@ -80,6 +86,9 @@ const PortfolioBacktestForm: React.FC<PortfolioBacktestFormProps> = ({ onSubmit,
// 닫아도 쓸모없는 Alert만 남는 문제가 있었다 (P2-29).
console.error('백테스트 실행 중 오류:', extractErrorMessage(error));
} finally {
// Siteverify tokens are single-use, including a rejected/failed attempt.
setTurnstileToken('');
setVerificationAttempt(value => value + 1);
actions.setLoading(false);
}
};
Expand Down Expand Up @@ -178,11 +187,14 @@ const PortfolioBacktestForm: React.FC<PortfolioBacktestFormProps> = ({ onSubmit,
</FormSection>
</div>

{verificationRequired && <TurnstileVerification siteKey={siteKey}
resetKey={verificationAttempt} onToken={setTurnstileToken} />}

{/* 제출 버튼 */}
<div className="flex justify-end pt-2 sm:pt-4 pb-6 sm:pb-8">
<Button
type="submit"
disabled={loading || state.ui.isLoading}
disabled={loading || state.ui.isLoading || (verificationRequired && !turnstileToken)}
size="lg"
className="w-full sm:w-auto sm:min-w-[240px] shadow-md hover:shadow-xl"
>
Expand Down
97 changes: 97 additions & 0 deletions src/features/backtest/components/TurnstileVerification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/shared/ui/button';

interface WidgetOptions {
sitekey: string;
action: string;
size: 'flexible';
'response-field': false;
callback: (token: string) => void;
'expired-callback': () => void;
'error-callback': () => void;
'timeout-callback': () => void;
}
export interface TurnstileApi {
render: (container: HTMLElement, options: WidgetOptions) => string;
remove: (id: string) => void;
}
declare global {
interface Window { turnstile?: TurnstileApi }
}

let scriptPromise: Promise<TurnstileApi> | undefined;
function loadTurnstile(): Promise<TurnstileApi> {
if (window.turnstile) return Promise.resolve(window.turnstile);
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise<TurnstileApi>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
const failed = () => {
clearTimeout(timer);
script.remove();
scriptPromise = undefined;
reject(new Error('Turnstile could not load'));
};
const timer = window.setTimeout(failed, 15_000);
script.onerror = failed;
script.onload = () => {
if (!window.turnstile) { failed(); return; }
clearTimeout(timer);
resolve(window.turnstile);
};
document.head.appendChild(script);
});
return scriptPromise;
}

interface Props {
siteKey: string;
resetKey: number;
onToken: (token: string) => void;
}

export function TurnstileVerification({ siteKey, resetKey, onToken }: Props) {
const container = useRef<HTMLDivElement>(null);
const [error, setError] = useState(false);
const [retry, setRetry] = useState(0);
useEffect(() => {
let active = true;
let api: TurnstileApi | undefined;
let widget: string | undefined;
onToken('');
if (siteKey) {
void loadTurnstile().then(loaded => {
if (!active || !container.current) return;
api = loaded;
const invalidate = () => { if (active) onToken(''); };
widget = api.render(container.current, {
sitekey: siteKey, action: 'backtest', size: 'flexible', 'response-field': false,
callback: token => { if (active) { setError(false); onToken(token); } },
'expired-callback': invalidate,
'error-callback': () => { if (active) { invalidate(); setError(true); } },
'timeout-callback': () => { if (active) { invalidate(); setError(true); } },
});
}).catch(() => { if (active) { onToken(''); setError(true); } });
}
return () => {
active = false;
if (widget !== undefined) api?.remove(widget);
};
}, [siteKey, resetKey, retry, onToken]);

return (
<div className="space-y-2 pt-4" aria-label="사람 인증">
<p className="text-sm text-muted-foreground">사람 인증을 완료하면 백테스트를 실행할 수 있습니다.</p>
<div ref={container} />
{(!siteKey || error) && (
<div role="alert" className="text-sm text-destructive">
인증을 불러오지 못했습니다. 네트워크와 차단 확장 프로그램을 확인해 주세요.
{siteKey && <Button type="button" variant="outline" className="ml-2" onClick={() => {
setError(false); setRetry(value => value + 1);
}}>인증 다시 시도</Button>}
</div>
)}
</div>
);
}
Loading