feat: 보안 + UX 개선
보안: - SSAFY OAuth URL https 스킴 검증 - CSRF 방어 X-Requested-With 헤더 추가 - 업로드 에러 상태코드별 메시지 분기 (413, 409, 5xx) UX: - Admin 페이지 Toast 알림 통합 - API 에러 메시지 한글화 (localizeError) - og:title, og:description 메타 태그 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="One of the plans — 멀티플레이어 보스 레이드 게임 플랫폼">
|
||||
<meta property="og:title" content="One of the plans">
|
||||
<meta property="og:description" content="One of the plans — 멀티플레이어 보스 레이드 게임 플랫폼">
|
||||
<title>One of the plans</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
/** 네트워크 에러 메시지를 한국어로 변환 */
|
||||
function localizeError(message) {
|
||||
if (typeof message !== 'string') return message;
|
||||
if (message.includes('Failed to fetch')) return '서버에 연결할 수 없습니다';
|
||||
if (message.includes('NetworkError')) return '네트워크에 연결할 수 없습니다';
|
||||
if (message.includes('AbortError') || message === 'The user aborted a request.') return '요청 시간이 초과되었습니다';
|
||||
return message;
|
||||
}
|
||||
|
||||
// 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유
|
||||
let refreshingPromise = null;
|
||||
|
||||
@@ -26,7 +35,7 @@ export async function tryRefresh() {
|
||||
}
|
||||
|
||||
async function doFetch(path, options, token) {
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
const headers = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...options.headers };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
return fetch(BASE + path, { ...options, headers, credentials: 'include' });
|
||||
}
|
||||
@@ -60,6 +69,7 @@ export async function apiFetch(path, options = {}, _retryCount = 0) {
|
||||
await delay(1000 * (_retryCount + 1));
|
||||
return apiFetch(path, options, _retryCount + 1);
|
||||
}
|
||||
e.message = localizeError(e.message);
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -115,6 +125,7 @@ export function apiUpload(path, file, onProgress) {
|
||||
xhr.withCredentials = true;
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress) {
|
||||
@@ -123,7 +134,7 @@ export function apiUpload(path, file, onProgress) {
|
||||
};
|
||||
|
||||
xhr.onload = () => resolve(xhr);
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류가 발생했습니다.'));
|
||||
xhr.onerror = () => reject(new Error('서버에 연결할 수 없습니다'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// TODO: Add tests for CRUD operations (load download info, upload launcher, upload game)
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getDownloadInfo } from '../../api/download';
|
||||
import { useToast } from '../toast/useToast';
|
||||
import UploadForm from './UploadForm';
|
||||
import './AdminCommon.css';
|
||||
|
||||
export default function DownloadAdmin() {
|
||||
const toast = useToast();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
@@ -20,6 +22,7 @@ export default function DownloadAdmin() {
|
||||
.catch((err) => {
|
||||
console.error('다운로드 정보 로드 실패:', err);
|
||||
setLoadError('배포 정보를 불러올 수 없습니다.');
|
||||
toast.error('배포 정보를 불러올 수 없습니다.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { apiUpload } from '../../api/client';
|
||||
import { useToast } from '../toast/useToast';
|
||||
|
||||
export default function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
|
||||
const toast = useToast();
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -27,15 +29,32 @@ export default function UploadForm({ title, hint, accept, endpoint, onSuccess })
|
||||
const { status, body } = await apiUpload(path, file, (p) => setProgress(p));
|
||||
if (status >= 200 && status < 300) {
|
||||
onSuccess(body);
|
||||
toast.success('업로드가 완료되었습니다.');
|
||||
setFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setProgress(0);
|
||||
} else if (status === 413) {
|
||||
const msg = '파일 크기가 너무 큽니다. 더 작은 파일을 선택해주세요.';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} else if (status === 409) {
|
||||
const msg = '동일한 파일이 이미 존재합니다.';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} else if (status >= 500) {
|
||||
const msg = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} else {
|
||||
setError(body.error || '업로드에 실패했습니다.');
|
||||
const msg = body.error || '업로드에 실패했습니다.';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
setProgress(0);
|
||||
}
|
||||
} catch {
|
||||
setError('네트워크 오류가 발생했습니다.');
|
||||
const msg = '네트워크 오류가 발생했습니다.';
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
setProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
|
||||
@@ -85,6 +85,9 @@ export default function LoginPage() {
|
||||
onClick={async () => {
|
||||
try {
|
||||
const data = await getSSAFYLoginURL();
|
||||
if (!data.url || !data.url.startsWith('https://')) {
|
||||
throw new Error('유효하지 않은 로그인 URL입니다.');
|
||||
}
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
setError('SSAFY 로그인 URL을 가져올 수 없습니다.');
|
||||
|
||||
Reference in New Issue
Block a user