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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user