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) {

View File

@@ -296,17 +296,116 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
// 화이트리스트로 5종만 허용한다.
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
export const TERM_KINDS: readonly TermKind[] = [
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
]
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 _meta.json 에 저장.
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
export type TermKind = string
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
export const BUILTIN_TERM_KINDS = ['map', 'resourcepack', 'mod', 'installer', 'installer-rp'] as const
export type BuiltinTermKind = typeof BUILTIN_TERM_KINDS[number]
/** builtin 라벨. 사용자 정의 kind 는 _meta.json 에 저장된 라벨을 쓴다. */
const BUILTIN_TERM_LABELS: Record<BuiltinTermKind, string> = {
'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<string, string>
}
const TERMS_META_FILE = '_meta.json'
async function loadTermsMeta(): Promise<TermsMeta> {
try {
const raw = await fsp.readFile(path.join(manifestTermsDirPath, TERMS_META_FILE), 'utf8')
const parsed = JSON.parse(raw)
const customLabels: Record<string, string> = {}
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
for (const [k, v] of Object.entries(parsed.customLabels as Record<string, unknown>)) {
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<void> {
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<TermItem[]> {
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<string>()
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<string> {
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
const meta = await loadTermsMeta()
return meta.customLabels[kind] ?? kind
}
export async function loadTerm(kind: TermKind): Promise<string> {
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<string> {
}
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
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<void> {
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<void> {
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/<file>`)에서 호출. _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<AccountEntry[]> {
try {
const raw = await fsp.readFile(accountFilePath, 'utf8')