import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import { manifestRootPath, manifestDirPath, accountFilePath } from './paths' import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types' 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' }, mods: [], resourcepacks: [], 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 } 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' const modsSource = Array.isArray(input.mods) ? input.mods : [] const mods = modsSource .map((entry) => { const value = entry as Partial return { name: typeof value?.name === 'string' ? value.name.trim() : '', downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : '' } }) .filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0) const resourcePacksSource = Array.isArray(input.resourcepacks) ? input.resourcepacks : [] const resourcepacks = resourcePacksSource .map((entry) => { const value = entry as Partial return { name: typeof value?.name === 'string' ? value.name.trim() : '', downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : '' } }) .filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0) 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, downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0 ? platform.downloadUrl.trim() : undefined }, mods, resourcepacks, 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 }) } 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 } }