feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
This commit is contained in:
@@ -114,11 +114,12 @@ export async function executeServerAction<TApi = unknown, TResult = TApi>(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// JSON 파싱 (백엔드가 HTML 에러 페이지 반환 시 안전 처리)
|
||||
// JSON 파싱 (PHP trailing output + HTML 에러 페이지 안전 처리)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let result: any;
|
||||
try {
|
||||
result = await response.json();
|
||||
const { safeResponseJson } = await import('./safe-json-parse');
|
||||
result = await safeResponseJson(response);
|
||||
} catch {
|
||||
const status = response.status;
|
||||
console.error(`[executeServerAction] JSON 파싱 실패 (${method} ${url}, status: ${status})`);
|
||||
|
||||
@@ -183,7 +183,19 @@ class ServerApiClient {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
// 방어적 JSON 파싱: PHP 백엔드가 JSON 뒤에 경고/에러 텍스트를 붙여 보내는 경우 대응
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
// JSON 뒤에 trailing garbage가 있는 경우 복구 시도
|
||||
const match = text.match(/^(\{[\s\S]*\}|\[[\s\S]*\])/);
|
||||
if (match) {
|
||||
console.warn('[ServerApiClient] Response contained trailing data, recovering:', text.slice(match[1].length, match[1].length + 100));
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
throw parseError;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
throw error;
|
||||
|
||||
@@ -74,7 +74,9 @@ async function doRefreshToken(refreshToken: string): Promise<RefreshResult> {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// 방어적 JSON 파싱: PHP가 JSON 뒤에 warning을 붙여 보내는 경우 대응
|
||||
const { safeResponseJson } = await import('./safe-json-parse');
|
||||
const data = await safeResponseJson<{ access_token: string; refresh_token: string; expires_in: number }>(response);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
98
src/lib/api/safe-json-parse.ts
Normal file
98
src/lib/api/safe-json-parse.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 방어적 JSON 파싱 유틸리티
|
||||
*
|
||||
* PHP 백엔드가 JSON 출력 후 warning/error 텍스트를 붙여 보내는 경우 대응.
|
||||
* 예: {"success":true,"data":[...]}<br />Warning: Undefined variable...
|
||||
*
|
||||
* JSON.parse는 유효한 JSON 이후 trailing data가 있으면
|
||||
* "Unexpected non-whitespace character after JSON at position N" 에러를 발생시킴.
|
||||
* 이때 position N까지가 유효한 JSON이므로, 해당 지점까지 잘라서 재파싱.
|
||||
*/
|
||||
|
||||
/**
|
||||
* PHP trailing output을 제거하고 JSON 파싱
|
||||
*
|
||||
* @param text - JSON 문자열 (trailing garbage 포함 가능)
|
||||
* @returns 파싱된 JSON 객체
|
||||
* @throws SyntaxError - 유효한 JSON이 아닌 경우 (복구 불가)
|
||||
*/
|
||||
export function safeJsonParse<T = unknown>(text: string): T {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) throw e;
|
||||
|
||||
// "Unexpected ... after JSON at position N" 패턴에서 position 추출
|
||||
const posMatch = e.message.match(/position\s+(\d+)/i);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
if (pos > 0) {
|
||||
try {
|
||||
const result = JSON.parse(text.substring(0, pos)) as T;
|
||||
console.warn(
|
||||
`[safeJsonParse] PHP trailing output detected (${text.length - pos} bytes). ` +
|
||||
`Trailing: ${text.substring(pos, pos + 200).replace(/\n/g, '\\n')}`
|
||||
);
|
||||
return result;
|
||||
} catch {
|
||||
// truncation으로도 실패하면 원래 에러 throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response 객체에서 방어적으로 JSON 파싱
|
||||
*
|
||||
* response.json() 대신 response.text() + safeJsonParse() 사용.
|
||||
* PHP trailing output이 있어도 유효한 JSON을 복구.
|
||||
*/
|
||||
export async function safeResponseJson<T = unknown>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
return safeJsonParse<T>(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 텍스트에서 trailing garbage 제거
|
||||
*
|
||||
* 프록시에서 사용: 파싱하지 않고 텍스트 레벨에서 trailing data만 제거.
|
||||
* JSON이 아니거나 trailing data가 없으면 원본 그대로 반환.
|
||||
*/
|
||||
export function stripJsonTrailingData(text: string): string {
|
||||
// JSON이 아닌 경우 원본 반환
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
// 정상 파싱 되면 trailing data 없음
|
||||
JSON.parse(text);
|
||||
return text;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) return text;
|
||||
|
||||
const posMatch = e.message.match(/position\s+(\d+)/i);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
if (pos > 0) {
|
||||
const truncated = text.substring(0, pos);
|
||||
try {
|
||||
// truncated가 유효한 JSON인지 확인
|
||||
JSON.parse(truncated);
|
||||
console.warn(
|
||||
`[stripJsonTrailingData] Removed ${text.length - pos} bytes of trailing data`
|
||||
);
|
||||
return truncated;
|
||||
} catch {
|
||||
// 복구 불가 → 원본 반환
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,10 @@ export async function downloadFileById(fileId: number, fileName?: string): Promi
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 경로로 새 탭에서 열기 (미리보기용)
|
||||
* @param filePath 파일 경로
|
||||
* 파일 ID로 새 탭에서 열기 (인라인 미리보기)
|
||||
* R2 스토리지 전환으로 /storage/ 직접 접근 불가 → /view 엔드포인트 사용
|
||||
* @param fileId 파일 ID
|
||||
*/
|
||||
export function openFileInNewTab(filePath: string): void {
|
||||
// 백엔드 파일 서빙 URL 구성
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const fileUrl = `${baseUrl}/storage/${filePath}`;
|
||||
window.open(fileUrl, '_blank');
|
||||
export function openFileInNewTab(fileId: number): void {
|
||||
window.open(`/api/proxy/files/${fileId}/view`, '_blank');
|
||||
}
|
||||
Reference in New Issue
Block a user