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 { try { const raw = await fsp.readFile(manifestRootPath, 'utf8') const parsed = JSON.parse(raw) as Partial 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 { 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 & Record): PackDefinition { const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new') const platform = (input.platform ?? {}) as Partial 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 { const filePath = path.join(manifestDirPath, `${packKey}.json`) try { const raw = await fsp.readFile(filePath, 'utf8') const parsed = JSON.parse(raw) as Partial 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 { 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 { 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 { 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 { 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 { 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 { 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() 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 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 => !!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 => !!entry && typeof entry === 'object') .map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) })) .filter((entry) => entry.url.length > 0) } } export async function loadPackList(packKey: string): Promise { 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 { 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 = { '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 } 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') } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '' throw error } } 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') 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 } }