From 704d58d3acb4c0a1e7c31b81d8478d38663cb1f3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 11 Jul 2026 12:10:55 +0900 Subject: [PATCH] optimize: crash guard, yt-dlp url validation, dead-key cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전체 코드 재검토 기반의 안전한 개선 1차 배치. - 메인 설치기에 전역 uncaughtException/unhandledRejection 가드 추가. nat-upnp (detectExternalIpUpnp)의 비동기 소켓 오류로 앱이 조용히 종료되던 잠재 크래시 방지(포트포워딩 도구 v0.3.18 과 동일 대비). - 서버: fetchVideoMeta/fetchPlaylistEntries 에 http(s) URL 검증 추가(운영자 입력이 yt-dlp 플래그로 오인되는 인자 주입 차단). /file/mods/:folder/index.json 의 async throw → next(error) 로 위임(unhandledRejection 방지). index 라우트 pack 정의 병렬 로드. - 죽은 i18n 키 정리: installer 로케일의 UPnP/run.bat 관련 19개 + pf 로케일 2개 제거(코드에서 미참조 확인). 크래시 가드용 log.internalError, youtube.invalidUrl 추가. v0.3.23. Co-Authored-By: Claude Opus 4 --- locales/installer-pf/ko-kr.json | 2 -- locales/installer/ko-kr.json | 22 ++-------------------- locales/server/ko-kr.json | 3 ++- package.json | 2 +- src/installer/main.ts | 10 ++++++++++ src/server/app.ts | 5 +++-- src/server/routes/index.ts | 6 +++--- src/server/youtube.ts | 9 +++++++++ 8 files changed, 30 insertions(+), 29 deletions(-) diff --git a/locales/installer-pf/ko-kr.json b/locales/installer-pf/ko-kr.json index 9471fce..29cda72 100644 --- a/locales/installer-pf/ko-kr.json +++ b/locales/installer-pf/ko-kr.json @@ -44,8 +44,6 @@ "cleanup": "이전 UPnP 매핑 정리 중…", "externalIpHttp": "외부 IP(HTTP): {{ip}}", "externalIpHttpFail": "외부 IP HTTP 조회 실패 → UPnP 게이트웨이로 폴백", - "externalIpUpnp": "외부 IP(UPnP): {{ip}}", - "externalIpFail": "외부 IP 확인 실패", "probeStart": "외부 포트 점검 시작…", "probeResult": "점검 결과: {{verdict}} ({{detail}})", "preForwarded": "이미 외부에서 접속 가능한 상태입니다.", diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index e5ec8a5..849d0c5 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -191,7 +191,6 @@ "fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.", "fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.", "portAllocFail": "포트를 할당할 수 없습니다.", - "upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.", "parseResponseFailed": "응답 파싱 실패: {{snippet}}" }, "log": { @@ -222,15 +221,10 @@ "skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.", "resourcepackDownload": "리소스팩 다운로드: {{url}}", "serverInstallPath": "서버 설치 경로: {{path}}", - "runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.", - "runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.", - "runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.", - "runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.", "mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}", "eulaAccepted": "EULA 동의 저장 완료.", "configEditorOpen": "서버 설정 편집기 실행: {{url}}", "portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}", - "upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...", "externalIpHttp": "외부 IP 확인(HTTP): {{ip}}", "externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...", "externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}", @@ -242,15 +236,6 @@ "probeVerdictUnknown": "확인 불가", "probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.", "ipUnknown": "(IP 미상)", - "upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...", - "upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.", - "upnpTryFail": "UPnP 시도 실패: {{message}}", - "upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.", - "upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...", - "upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).", - "upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.", - "upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.", - "upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.", "upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}", "upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).", "upnpExternalErr": "UPnP externalIp 오류: {{message}}", @@ -261,10 +246,6 @@ "detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}", "detailIfconfigFail": "ifconfig.co 실패={{error}}", "detailNone": "결과 없음", - "upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}", - "upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.", - "upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)", - "upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).", "platformDownload": "플랫폼({{type}}) 다운로드: {{url}}", "platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)", "platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.", @@ -301,7 +282,8 @@ "launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}", "launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).", "launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.", - "launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요." + "launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요.", + "internalError": "내부 오류(무시하고 계속): {{message}}" }, "candidates": { "winProgramFiles86": "Win32 설치(Program Files (x86))", diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index 4c0ad09..b8adf14 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -229,6 +229,7 @@ "ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}", "ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}", "ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}", - "tooManyRedirects": "redirect 가 너무 많습니다." + "tooManyRedirects": "redirect 가 너무 많습니다.", + "invalidUrl": "올바르지 않은 URL 입니다. http/https 주소만 허용됩니다." } } diff --git a/package.json b/package.json index 958620b..468bb09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.22", + "version": "0.3.23", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/installer/main.ts b/src/installer/main.ts index da959ec..3ba5f0b 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -98,6 +98,16 @@ function sendLog(line: string): void { mainWindow.webContents.send('log', stamped) } +// nat-upnp(detectExternalIpUpnp) 등이 콜백 밖에서 비동기 소켓 오류를 내면 처리되지 +// 않아 Electron 메인이 종료되며 창이 갑자기 닫힐 수 있다. 전역 가드로 잡아 로그만 +// 남기고 앱은 계속 살려 둔다(포트포워딩 전용 도구 v0.3.18 과 동일한 대비책). +process.on('uncaughtException', (err) => { + try { sendLog(t('log.internalError', { message: (err as Error)?.message || String(err) })) } catch {} +}) +process.on('unhandledRejection', (reason) => { + try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {} +}) + function fetchBuffer(url: string): Promise { return new Promise((resolve, reject) => { const target = new URL(url) diff --git a/src/server/app.ts b/src/server/app.ts index 7389a69..014358e 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -143,7 +143,7 @@ app.use((req, res, next) => { }) // 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용. -app.get('/file/mods/:folder/index.json', async (req, res) => { +app.get('/file/mods/:folder/index.json', async (req, res, next) => { const folder = req.params.folder if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) { res.status(404).json({ files: [] }) @@ -162,7 +162,8 @@ app.get('/file/mods/:folder/index.json', async (req, res) => { res.status(404).json({ files: [] }) return } - throw error + // async 핸들러의 throw 는 Express4 가 잡지 못해 unhandledRejection 이 되므로 next 로 위임. + next(error) } }) diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 8aa508d..1329b9d 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -8,9 +8,9 @@ indexRouter.get('/', async (_req, res, next) => { const manifest = await readManifest() const definitionMap = new Map>>() const keys = await listPackKeys() - for (const key of keys) { - definitionMap.set(key, await loadPackDefinition(key)) - } + // 팩 정의를 병렬 로드(op.ts 와 동일 패턴). 순차 await 보다 빠름. + const definitions = await Promise.all(keys.map((key) => loadPackDefinition(key))) + keys.forEach((key, i) => definitionMap.set(key, definitions[i])) const packs = manifest.packs.map((entry) => ({ name: entry.name, file: entry.file, diff --git a/src/server/youtube.ts b/src/server/youtube.ts index 2978e74..7baaf85 100644 --- a/src/server/youtube.ts +++ b/src/server/youtube.ts @@ -299,7 +299,15 @@ async function runYtDlp(args: string[], makeError: (code: string, detail: string * 단일 영상 URL 의 메타데이터를 가져온다. * `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음. */ +/** 운영자 입력 URL 이 yt-dlp 인자(플래그)로 오인되지 않도록 http(s) 스킴만 허용. */ +function assertHttpUrl(url: string): void { + if (!/^https?:\/\//i.test(url.trim())) { + throw new Error(t('youtube.invalidUrl')) + } +} + export async function fetchVideoMeta(url: string): Promise { + assertHttpUrl(url) const stdout = await runYtDlp( ['--dump-json', '--no-warnings', '--no-playlist', '--skip-download', url], (code, detail) => new Error(t('youtube.ytdlpVideoFailed', { code, detail })) @@ -327,6 +335,7 @@ export async function fetchVideoMeta(url: string): Promise { + assertHttpUrl(url) const stdout = await runYtDlp( ['--flat-playlist', '--dump-json', '--no-warnings', url], (code, detail) => new Error(t('youtube.ytdlpPlaylistFailed', { code, detail }))