terms: dark editor BG, vertical row layout, add/delete custom kinds

- 약관 편집기 배경/슬래시 메뉴를 사이트 다크 팔레트로 통일 (흰 배경 + 흰 글씨 가시성 문제 해결)
- 약관 목록을 가로 풀폭 1줄씩 세로로 쌓이는 레이아웃으로 변경
- 사용자 정의 약관 추가/삭제 지원
  - manifest/terms/_meta.json 에 라벨 저장
  - builtin 5종(map/resourcepack/mod/installer/installer-rp)은 삭제 불가, "기본" 배지 표시
  - kind 식별자 규칙: 소문자/숫자/하이픈 32자 이내
  - 공개 라우트 /manifest/terms/<file>.md 는 isPublicTermsFile() 로 _meta.json 차단
- 0.3.0 → 0.3.1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 01:12:10 +09:00
parent ffb2048627
commit c14b0507c7
7 changed files with 338 additions and 53 deletions

View File

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

View File

@@ -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/<kind>.md 로 받아 표시한다.
const TERM_LABELS: Record<TermKind, string> = {
'map': '맵 약관',
'resourcepack': '리소스팩 약관',
'mod': '모드 약관',
'installer': '설치기 약관',
'installer-rp': '리소스팩 설치기 약관'
}
// 약관(Markdown) 편집기. builtin 5종은 항상 존재하고 삭제 불가, 그 외 임의 kind 는
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<kind>.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) {