diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index eac1bd8..80583b6 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -156,7 +156,20 @@ "slashDivider": "구분선", "slashQuote": "인용", "slashCode": "코드", - "leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?" + "leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?", + "builtinBadge": "기본", + "addHeading": "약관 추가", + "kindLabel": "식별자", + "kindPlaceholder": "예: privacy", + "kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.", + "labelLabel": "표시 이름", + "labelPlaceholder": "예: 개인정보 처리방침", + "addButton": "추가", + "deleteButton": "삭제", + "deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.", + "invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.", + "createFailed": "약관 추가 실패", + "cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다." }, "datapack": { "browserTitle": "데이터팩 수정", diff --git a/package.json b/package.json index 4cda4c6..ae563bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.0", + "version": "0.3.1", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/public/termsEditor.css b/public/termsEditor.css index 5e79a38..2a80f32 100644 --- a/public/termsEditor.css +++ b/public/termsEditor.css @@ -1,7 +1,7 @@ /* Notion 스타일 약관 편집기 전용 스타일. * 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기 * 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에 - * 절대 위치로 띄운다. */ + * 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */ .termsEditorWrap { position: relative; @@ -12,9 +12,10 @@ width: 100%; min-height: 60vh; padding: 16px 18px; - border: 1px solid #d5d5d5; + border: 1px solid var(--border, #30363d); border-radius: 8px; - background: #fff; + background: var(--bg-card, #1f242c); + color: var(--text, #e6edf3); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; line-height: 1.7; @@ -22,19 +23,21 @@ box-sizing: border-box; outline: none; white-space: pre-wrap; + caret-color: var(--accent, #58a6ff); } .termsEditor:focus { - border-color: #5b8def; - box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2); + border-color: var(--accent, #58a6ff); + box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25); } .termsPreview { min-height: 60vh; padding: 16px 18px; - border: 1px solid #d5d5d5; + border: 1px solid var(--border, #30363d); border-radius: 8px; - background: #fafafa; + background: var(--bg-alt, #161b22); + color: var(--text, #e6edf3); font-size: 14px; line-height: 1.7; box-sizing: border-box; @@ -46,23 +49,30 @@ .termsPreview p { margin: 6px 0; } .termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; } .termsPreview li { margin: 2px 0; } -.termsPreview hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; } +.termsPreview hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; } .termsPreview blockquote { - margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ddd; color: #555; + margin: 8px 0; padding: 4px 12px; + border-left: 3px solid var(--border, #30363d); + color: var(--text-muted, #8b949e); } .termsPreview code { - background: #eee; padding: 1px 5px; border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + padding: 1px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; } .termsPreview pre { - background: #f0f0f0; padding: 10px 12px; border-radius: 6px; overflow: auto; + background: rgba(0, 0, 0, 0.4); + padding: 10px 12px; border-radius: 6px; overflow: auto; } .termsPreview pre code { background: transparent; padding: 0; } -.termsPreview a { color: #2664d8; text-decoration: underline; word-break: break-all; } +.termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; } .termsPreview details { - margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px; - background: #fff; padding: 4px 10px; + margin: 6px 0; + border: 1px solid var(--border, #30363d); + border-radius: 6px; + background: var(--bg-card, #1f242c); + padding: 4px 10px; } .termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; } @@ -73,10 +83,11 @@ min-width: 220px; max-height: 280px; overflow-y: auto; - background: #fff; - border: 1px solid #ccc; + background: var(--bg-alt, #161b22); + color: var(--text, #e6edf3); + border: 1px solid var(--border, #30363d); border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); padding: 4px; font-size: 13px; } @@ -89,7 +100,7 @@ } .slashMenu .slashItem:hover, .slashMenu .slashItem.active { - background: #eef2ff; + background: var(--bg-card, #1f242c); } -.slashMenu .slashItem strong { font-size: 13px; } -.slashMenu .slashItem span { color: #888; font-size: 11px; } +.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); } +.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; } diff --git a/src/server/app.ts b/src/server/app.ts index 777e163..c07e90c 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -6,6 +6,7 @@ import { manifestRootPath, manifestDirPath, manifestTermsDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js' +import { isPublicTermsFile } from '../shared/store.js' import { loadEnv } from '../shared/env.js' import { t, localeDict } from './i18n.js' import { indexRouter } from './routes/index.js' @@ -62,11 +63,11 @@ app.get('/manifest.json', (_req, res) => { res.sendFile(manifestRootPath) }) -// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용. +// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다. +// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단. app.get('/manifest/terms/:fileName', (req, res) => { const fileName = req.params.fileName - // 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md - if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) { + if (!isPublicTermsFile(fileName)) { res.status(404).send('Not Found') return } diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts index b0d3f3f..029de72 100644 --- a/src/server/routes/op.ts +++ b/src/server/routes/op.ts @@ -2,8 +2,14 @@ import { Router } from 'express' import archiver from 'archiver' import { createPack, + createTerm, deletePackKeys, + deleteTerm, + getTermLabel, + isBuiltinTermKind, + isTermKind, listPackKeys, + listTermsWithLabels, loadPackDefinition, loadPackList, loadTerm, @@ -13,11 +19,8 @@ import { renamePack, sanitizePackKey, saveTerm, - savePackList, - isTermKind, - TERM_KINDS + savePackList } from '../../shared/store.js' -import type { TermKind } from '../../shared/store.js' import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { requireAuth } from '../middleware/auth.js' @@ -301,19 +304,49 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res, }) // ─── /op/agreement ───────────────────────────────────────────────────── -// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로 -// 마크다운을 작성하고, 인스톨러는 /manifest/terms/.md 로 받아 표시한다. -const TERM_LABELS: Record = { - 'map': '맵 약관', - 'resourcepack': '리소스팩 약관', - 'mod': '모드 약관', - 'installer': '설치기 약관', - 'installer-rp': '리소스팩 설치기 약관' -} +// 약관(Markdown) 편집기. builtin 5종은 항상 존재하고 삭제 불가, 그 외 임의 kind 는 +// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/.md 로 받아 표시한다. -opRouter.get('/op/agreement', requireAuth, (req, res) => { - const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] })) - res.render('op/terms', { userId: req.session.userId, items }) +opRouter.get('/op/agreement', requireAuth, async (req, res, next) => { + try { + const items = await listTermsWithLabels() + res.render('op/terms', { userId: req.session.userId, items }) + } catch (error) { + next(error) + } +}) + +opRouter.post('/op/agreement/create', requireAuth, async (req, res, next) => { + try { + const kindInput = pickFirstValue(req.body.kind).trim().toLowerCase() + const label = pickFirstValue(req.body.label) + if (!isTermKind(kindInput)) { + res.status(400).send(t('terms.invalidKind')) + return + } + await createTerm(kindInput, label) + res.redirect(`/op/agreement/${kindInput}`) + } catch (error) { + res.status(400).send((error as Error).message || t('terms.createFailed')) + } +}) + +opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next) => { + try { + const kind = pickFirstValue(req.params.kind) + if (!isTermKind(kind)) { + res.status(400).send(t('terms.invalidKind')) + return + } + if (isBuiltinTermKind(kind)) { + res.status(400).send(t('terms.cannotDeleteBuiltin')) + return + } + await deleteTerm(kind) + res.redirect('/op/agreement') + } catch (error) { + next(error) + } }) opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => { @@ -324,10 +357,11 @@ opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => { return } const content = await loadTerm(kind) + const label = await getTermLabel(kind) res.render('op/termsEditor', { userId: req.session.userId, kind, - label: TERM_LABELS[kind], + label, content }) } catch (error) { diff --git a/src/shared/store.ts b/src/shared/store.ts index b8dd6ed..e950e90 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -296,17 +296,116 @@ export async function savePackList(packKey: string, list: PackList): Promise = { + 'map': '맵 약관', + 'resourcepack': '리소스팩 약관', + 'mod': '모드 약관', + 'installer': '설치기 약관', + 'installer-rp': '리소스팩 설치기 약관' +} + +const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/ export function isTermKind(value: unknown): value is TermKind { - return typeof value === 'string' && (TERM_KINDS as readonly string[]).includes(value) + return typeof value === 'string' && TERM_KIND_RE.test(value) +} + +export function isBuiltinTermKind(value: string): value is BuiltinTermKind { + return (BUILTIN_TERM_KINDS as readonly string[]).includes(value) +} + +interface TermsMeta { + /** 사용자 정의 kind 라벨. builtin 은 들어가지 않는다. */ + customLabels: Record +} + +const TERMS_META_FILE = '_meta.json' + +async function loadTermsMeta(): Promise { + try { + const raw = await fsp.readFile(path.join(manifestTermsDirPath, TERMS_META_FILE), 'utf8') + const parsed = JSON.parse(raw) + const customLabels: Record = {} + if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') { + for (const [k, v] of Object.entries(parsed.customLabels as Record)) { + if (typeof v === 'string' && TERM_KIND_RE.test(k)) customLabels[k] = v + } + } + return { customLabels } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { customLabels: {} } + throw error + } +} + +async function saveTermsMeta(meta: TermsMeta): Promise { + await fsp.mkdir(manifestTermsDirPath, { recursive: true }) + await fsp.writeFile( + path.join(manifestTermsDirPath, TERMS_META_FILE), + `${JSON.stringify(meta, null, 2)}\n`, + 'utf8' + ) +} + +export interface TermItem { + kind: string + label: string + builtin: boolean +} + +/** + * 디스크의 .md 파일 + _meta.json 을 합쳐 약관 목록을 만든다. + * - builtin 5종은 파일 존재 여부와 무관하게 항상 포함된다 (인스톨러가 fetch 하므로). + * - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함. + * - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지. + */ +export async function listTermsWithLabels(): Promise { + const meta = await loadTermsMeta() + const items: TermItem[] = [] + for (const kind of BUILTIN_TERM_KINDS) { + items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true }) + } + // 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출. + let onDisk: string[] = [] + try { + onDisk = await fsp.readdir(manifestTermsDirPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const customKinds = new Set() + for (const fname of onDisk) { + if (!fname.toLowerCase().endsWith('.md')) continue + const kind = fname.slice(0, -3) + if (!TERM_KIND_RE.test(kind)) continue + if (isBuiltinTermKind(kind)) continue + customKinds.add(kind) + } + // _meta.json 에 라벨이 등록된 것만 노출 (라벨 없는 orphan .md 는 무시). + for (const kind of Object.keys(meta.customLabels).sort((a, b) => a.localeCompare(b, 'ko'))) { + if (!customKinds.has(kind)) continue + items.push({ kind, label: meta.customLabels[kind], builtin: false }) + } + return items +} + +export async function getTermLabel(kind: string): Promise { + if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind] + const meta = await loadTermsMeta() + return meta.customLabels[kind] ?? kind } export async function loadTerm(kind: TermKind): Promise { + if (!isTermKind(kind)) return '' const filePath = path.join(manifestTermsDirPath, `${kind}.md`) try { return await fsp.readFile(filePath, 'utf8') @@ -317,12 +416,60 @@ export async function loadTerm(kind: TermKind): Promise { } export async function saveTerm(kind: TermKind, markdown: string): Promise { + if (!isTermKind(kind)) throw new Error('invalid term kind') await fsp.mkdir(manifestTermsDirPath, { recursive: true }) const filePath = path.join(manifestTermsDirPath, `${kind}.md`) const normalized = (markdown ?? '').replace(/\r\n/g, '\n') await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8') } +/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */ +export async function createTerm(kind: string, label: string): Promise { + if (!isTermKind(kind)) throw new Error('invalid term kind') + if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created') + const cleanLabel = label.trim() + if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length') + const meta = await loadTermsMeta() + if (meta.customLabels[kind]) throw new Error('term kind already exists') + await fsp.mkdir(manifestTermsDirPath, { recursive: true }) + const filePath = path.join(manifestTermsDirPath, `${kind}.md`) + // 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음). + try { + await fsp.access(filePath) + throw new Error('term file already exists') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8') + meta.customLabels[kind] = cleanLabel + await saveTermsMeta(meta) +} + +/** 사용자 정의 약관 삭제. builtin 은 거부. */ +export async function deleteTerm(kind: string): Promise { + if (!isTermKind(kind)) throw new Error('invalid term kind') + if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted') + const filePath = path.join(manifestTermsDirPath, `${kind}.md`) + try { + await fsp.unlink(filePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const meta = await loadTermsMeta() + if (meta.customLabels[kind]) { + delete meta.customLabels[kind] + await saveTermsMeta(meta) + } +} + +/** 공개 라우트(`/manifest/terms/`)에서 호출. _meta.json 같은 시스템 파일을 차단하기 위함. */ +export function isPublicTermsFile(fileName: string): boolean { + // .md 만 허용, 이름 규칙 일치, builtin 또는 정상 kind 패턴. + if (!fileName.toLowerCase().endsWith('.md')) return false + const kind = fileName.slice(0, -3) + return TERM_KIND_RE.test(kind) +} + export async function readAccounts(): Promise { try { const raw = await fsp.readFile(accountFilePath, 'utf8') diff --git a/views/op/terms.ejs b/views/op/terms.ejs index bbe839b..7122441 100644 --- a/views/op/terms.ejs +++ b/views/op/terms.ejs @@ -5,6 +5,48 @@ <%= t('terms.browserTitle') %> + <%- include('../partials/navbar', { userId }) %> @@ -19,16 +61,53 @@

<%= t('terms.hint') %>

-
+
<% items.forEach(function (item) { %> -
+ +
+

<%= t('terms.addHeading') %>

+
+
+ + + <%= t('terms.kindHint') %> +
+
+ + +
+
+ + +
+
+