feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬

- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
This commit is contained in:
유병철
2026-03-11 22:32:58 +09:00
parent e9ac2470e1
commit ea6ca335f1
24 changed files with 625 additions and 139 deletions

View File

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

View File

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

View File

@@ -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,

View 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;
}
}

View File

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