- Login form/route accepts password only; matched account row provides session userId
- PackDefinition: replace packPath with mapPath (.mc_custom/saves) and serverPath (server install dir); editor exposes two .zip fields
- Installer resolves relative platform/map/server URLs against manifest origin under /file/{platforms,maps,servers}/<name>; downloads and extracts the zips
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
232 lines
8.2 KiB
TypeScript
232 lines
8.2 KiB
TypeScript
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<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' },
|
|
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<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'
|
|
|
|
const modsSource = Array.isArray(input.mods) ? input.mods : []
|
|
const mods = modsSource
|
|
.map((entry) => {
|
|
const value = entry as Partial<PackDefinition['mods'][number]>
|
|
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<PackDefinition['resourcepacks'][number]>
|
|
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<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 })
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|