From 05dc9d716616fcdd2dae711985084c69b378b935 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 20 May 2026 01:39:28 +0900 Subject: [PATCH] terms: seed-on-fetch + rename/delete sync (v0.3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - public route `/manifest/terms/:packKey/:fileName` 가 sendFile 전에 `ensurePackTermsDir(packKey)` 를 호출하도록 수정. 관리자가 사이트 약관 페이지를 한 번도 열지 않은 fresh 배포에서도 설치기가 정상적으로 약관을 받을 수 있다. `loadPackDefinition` 으로 실제 pack 만 허용해 임의 키로 빈 폴더가 생성되는 것을 차단. - `renamePack`: pack JSON 이름이 바뀌면 `manifest/terms//` 도 `/` 로 함께 rename. - `deletePackKeys`: pack 삭제 시 약관 폴더도 `fs.rm` 으로 정리 — 동일 key 재생성 시 옛 약관 부활 방지. - `ensurePackTermsDir` export. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- src/server/app.ts | 37 ++++++++++++++++++++++++++----------- src/shared/store.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b4d47fa..4f40de1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.2", + "version": "0.3.3", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/server/app.ts b/src/server/app.ts index 82ee5c5..815cb90 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -6,7 +6,7 @@ import { manifestRootPath, manifestDirPath, manifestTermsDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js' -import { isPublicTermsFile } from '../shared/store.js' +import { ensurePackTermsDir, isPublicTermsFile, loadPackDefinition } from '../shared/store.js' import { loadEnv } from '../shared/env.js' import { t, localeDict } from './i18n.js' import { indexRouter } from './routes/index.js' @@ -66,17 +66,32 @@ app.get('/manifest.json', (_req, res) => { // 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다. // 음악퀴즈(pack) 별로 manifest/terms//.md 에서 노출한다. // _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단. -app.get('/manifest/terms/:packKey/:fileName', (req, res) => { - const { packKey, fileName } = req.params - if (!isPublicTermsFile(packKey, fileName)) { - res.status(404).send('Not Found') - return +// +// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을 +// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1 +// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가 +// 생성되는 것은 loadPackDefinition 으로 차단. +app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => { + try { + const { packKey, fileName } = req.params + if (!isPublicTermsFile(packKey, fileName)) { + res.status(404).send('Not Found') + return + } + const pack = await loadPackDefinition(packKey) + if (!pack) { + res.status(404).send('Not Found') + return + } + await ensurePackTermsDir(packKey) + res.type('text/markdown; charset=utf-8') + res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => { + if (!err || res.headersSent) return + res.status(404).send('Not Found') + }) + } catch (error) { + next(error) } - res.type('text/markdown; charset=utf-8') - res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => { - if (!err || res.headersSent) return - res.status(404).send('Not Found') - }) }) // 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용. diff --git a/src/shared/store.ts b/src/shared/store.ts index eb91d8d..7564253 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -178,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise { } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } + // pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때 + // 옛 약관이 부활하는 것을 막기 위함. + const termsDir = path.join(manifestTermsDirPath, key) + try { + await fsp.rm(termsDir, { recursive: true, force: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } await syncManifestWith(key, '', 'remove') } } @@ -198,6 +206,19 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } + // 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이 + // 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다. + const oldTermsDir = path.join(manifestTermsDirPath, oldKey) + const newTermsDir = path.join(manifestTermsDirPath, safeNew) + try { + await fsp.rename(oldTermsDir, newTermsDir) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + // 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다 + // (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때 + // 새 폴더 내용이 정상적으로 사용된다). + if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error + } await syncManifestWith(oldKey, '', 'remove') } await syncManifestWith(safeNew, pack.name, 'upsert') @@ -347,8 +368,12 @@ function isValidPackKey(packKey: string): boolean { * 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md` * 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다. * 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다. + * + * 공개 라우트(`/manifest/terms//`) 에서도 호출되므로 export 한다. + * 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다 + * (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다). */ -async function ensurePackTermsDir(packKey: string): Promise { +export async function ensurePackTermsDir(packKey: string): Promise { const dir = termsDirForPack(packKey) try { await fsp.access(dir)