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:
2026-03-18 10:37:49 +09:00
parent 1a3be5f76b
commit 555749b953
5 changed files with 43 additions and 4 deletions

View File

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