Files
minecraft_launcher/src/shared/store.ts
claude-bot c14b0507c7 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>
2026-05-20 01:12:10 +09:00

485 lines
18 KiB
TypeScript

import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
manifestRootPath, manifestDirPath, manifestTermsDirPath,
accountFilePath, fileListDirPath
} from './paths.js'
import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
PackList, MusicListEntry, ImageListEntry
} from './types.js'
export async function readManifest(): Promise<Manifest> {
try {
const raw = await fsp.readFile(manifestRootPath, 'utf8')
const parsed = JSON.parse(raw) as Partial<Manifest>
if (!parsed || !Array.isArray(parsed.packs)) {
return { packs: [] }
}
return {
packs: parsed.packs.filter((entry): entry is ManifestEntry =>
typeof entry?.name === 'string' && typeof entry?.file === 'string')
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { packs: [] }
}
throw error
}
}
export async function writeManifest(manifest: Manifest): Promise<void> {
await fsp.writeFile(manifestRootPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
}
export function defaultPackDefinition(name: string): PackDefinition {
return {
name,
mcVersion: '1.20.1',
platform: { type: 'vanilla' },
modsFolder: '',
resourcepackPath: '',
outputPackName: '',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 2048,
clientRecommendedRam: 4096,
mapPath: '',
serverPath: ''
}
}
function sanitizeZipFileName(input: unknown): string {
if (typeof input !== 'string') return ''
const trimmed = input.trim().replace(/^\/+/, '')
if (trimmed.length === 0) return ''
// 빈 값 허용, .zip 으로 끝나야 함, 경로 탈출 방지
if (trimmed.includes('..') || trimmed.includes('\\')) return ''
if (!/\.zip$/i.test(trimmed)) return ''
return trimmed
}
// 모드 폴더명: 영문/숫자/언더스코어/하이픈만 허용. 빈 값 허용.
function sanitizeFolderName(input: unknown): string {
if (typeof input !== 'string') return ''
const trimmed = input.trim().replace(/^\/+|\/+$/g, '')
if (trimmed.length === 0) return ''
if (!/^[a-zA-Z0-9_\-]+$/.test(trimmed)) return ''
return trimmed
}
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
const platformType = ALLOWED_LOADERS.includes(platform.type as LoaderType)
? (platform.type as LoaderType)
: 'vanilla'
return {
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
? input.mcVersion.trim()
: fallback.mcVersion,
platform: {
type: platformType,
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
downloadUrl: platformType !== 'vanilla'
&& typeof platform.downloadUrl === 'string'
&& platform.downloadUrl.trim().length > 0
? platform.downloadUrl.trim()
: undefined,
loaderVersion: platformType === 'fabric'
&& typeof (platform as { loaderVersion?: unknown }).loaderVersion === 'string'
&& ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim().length > 0
? ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim()
: undefined
},
modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
mapPath: sanitizeZipFileName(input.mapPath),
serverPath: sanitizeZipFileName(input.serverPath)
}
}
function clampNumber(input: unknown, fallback: number): number {
const value = typeof input === 'number' ? input : Number(input)
if (!Number.isFinite(value) || value <= 0) return fallback
return Math.floor(value)
}
export function packKeyFromFile(fileName: string): string {
return fileName.replace(/\.json$/i, '')
}
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
const filePath = path.join(manifestDirPath, `${packKey}.json`)
try {
const raw = await fsp.readFile(filePath, 'utf8')
const parsed = JSON.parse(raw) as Partial<PackDefinition>
return normalizePackDefinition(parsed)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
export async function savePackDefinition(packKey: string, pack: PackDefinition): Promise<void> {
await fsp.mkdir(manifestDirPath, { recursive: true })
const filePath = path.join(manifestDirPath, `${packKey}.json`)
await fsp.writeFile(filePath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8')
}
export async function listPackKeys(): Promise<string[]> {
try {
const entries = await fsp.readdir(manifestDirPath)
return entries
.filter((name) => name.toLowerCase().endsWith('.json'))
.map(packKeyFromFile)
.sort((a, b) => a.localeCompare(b, 'ko'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}
export async function nextNewKey(): Promise<string> {
const used = new Set(await listPackKeys())
if (!used.has('new')) return 'new'
for (let i = 2; i < 1000; i += 1) {
const candidate = `new${i}`
if (!used.has(candidate)) return candidate
}
return `new-${Date.now()}`
}
export async function createPack(): Promise<{ key: string; pack: PackDefinition }> {
const key = await nextNewKey()
const pack = defaultPackDefinition(key)
await savePackDefinition(key, pack)
await syncManifestWith(key, pack.name, 'add')
return { key, pack }
}
export async function deletePackKeys(keys: string[]): Promise<void> {
for (const key of keys) {
if (!key) continue
const filePath = path.join(manifestDirPath, `${key}.json`)
try {
await fsp.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await syncManifestWith(key, '', 'remove')
}
}
export async function renamePack(oldKey: string, newKey: string, pack: PackDefinition): Promise<string> {
const safeNew = sanitizePackKey(newKey) || oldKey
const targetPath = path.join(manifestDirPath, `${safeNew}.json`)
const sourcePath = path.join(manifestDirPath, `${oldKey}.json`)
if (safeNew !== oldKey && fs.existsSync(targetPath)) {
throw new Error(`이미 ${safeNew}.json 이름의 음악퀴즈가 있습니다.`)
}
await savePackDefinition(safeNew, pack)
if (safeNew !== oldKey) {
try {
await fsp.unlink(sourcePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await syncManifestWith(oldKey, '', 'remove')
}
await syncManifestWith(safeNew, pack.name, 'upsert')
return safeNew
}
export function sanitizePackKey(value: string): string {
return value.replace(/[^a-zA-Z0-9_\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
}
type ManifestSyncAction = 'add' | 'remove' | 'upsert'
async function syncManifestWith(key: string, name: string, action: ManifestSyncAction): Promise<void> {
const manifest = await readManifest()
const filtered = manifest.packs.filter((entry) => entry.file !== key)
if (action === 'remove') {
await writeManifest({ packs: filtered })
return
}
filtered.push({ name: name || key, file: key })
await writeManifest({ packs: filtered })
}
function defaultPackList(): PackList {
return { musicPlaylistUrl: '', imagePlaylistUrl: '', music: [], images: [] }
}
function sanitizeStr(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function sanitizeNumber(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(n) || n < 0) return 0
return Math.floor(n)
}
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
function sanitizeAliases(value: unknown): string[] {
if (!Array.isArray(value)) return []
const out: string[] = []
const seen = new Set<string>()
for (const item of value) {
const s = sanitizeStr(item)
if (!s) continue
if (seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
}
export function normalizePackList(input: unknown): PackList {
const fallback = defaultPackList()
if (!input || typeof input !== 'object') return fallback
const obj = input as Record<string, unknown>
const music = Array.isArray(obj.music) ? obj.music : []
const images = Array.isArray(obj.images) ? obj.images : []
return {
musicPlaylistUrl: sanitizeStr(obj.musicPlaylistUrl),
imagePlaylistUrl: sanitizeStr(obj.imagePlaylistUrl),
music: music
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): MusicListEntry => ({
url: sanitizeStr(entry.url),
title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec),
aliases: sanitizeAliases(entry.aliases)
}))
.filter((entry) => entry.url.length > 0),
images: images
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) }))
.filter((entry) => entry.url.length > 0)
}
}
export async function loadPackList(packKey: string): Promise<PackList> {
const filePath = path.join(fileListDirPath, `${packKey}.json`)
try {
const raw = await fsp.readFile(filePath, 'utf8')
return normalizePackList(JSON.parse(raw))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultPackList()
throw error
}
}
export async function savePackList(packKey: string, list: PackList): Promise<void> {
await fsp.mkdir(fileListDirPath, { recursive: true })
const filePath = path.join(fileListDirPath, `${packKey}.json`)
const normalized = normalizePackList(list)
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
}
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
// - 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_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')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
throw error
}
}
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')
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((entry): entry is AccountEntry =>
typeof entry?.id === 'string' && typeof entry?.password === 'string')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}