import { Router } from 'express' import archiver from 'archiver' import { createPack, createTerm, deletePackKeys, deleteTerm, getTermEntry, importTerms, isTermKind, listPackKeys, listTermsWithLabels, loadPackDefinition, loadPackList, loadTerm, normalizePackDefinition, normalizePackList, readAccounts, renamePack, sanitizePackKey, saveTerm, savePackList, setTermVisibility } from '../../shared/store.js' import { fetchReleaseVersions } from '../../shared/mojang.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { requireAuth } from '../middleware/auth.js' import type { PackDefinition, PackList } from '../../shared/types.js' import { t } from '../i18n.js' import { buildSongsMcfunction } from '../datapack.js' export const opRouter = Router() function pickFirstValue(value: unknown): string { if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : '' return typeof value === 'string' ? value : '' } function pickStringArray(value: unknown): string[] { if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === 'string') } if (typeof value === 'string') return [value] return [] } opRouter.get('/op', (req, res) => { if (req.session?.userId) { res.redirect('/op/dashboard') return } res.render('op/login', { error: null }) }) opRouter.post('/op', async (req, res, next) => { try { const password = pickFirstValue(req.body.password) const accounts = await readAccounts() const matched = accounts.find((entry) => entry.password === password) if (!matched) { res.status(401).render('op/login', { error: t('login.wrongPassword') }) return } req.session.userId = matched.id res.redirect('/op/dashboard') } catch (error) { next(error) } }) opRouter.post('/op/logout', (req, res) => { req.session.destroy(() => { res.redirect('/op') }) }) opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => { try { const keys = await listPackKeys() const items = await Promise.all(keys.map(async (key) => ({ key, definition: await loadPackDefinition(key) }))) res.render('op/dashboard', { userId: req.session.userId, items }) } catch (error) { next(error) } }) opRouter.post('/op/dashboard/create', requireAuth, async (_req, res, next) => { try { const { key } = await createPack() res.redirect(`/op/dashboard/${key}`) } catch (error) { next(error) } }) opRouter.post('/op/dashboard/delete', requireAuth, async (req, res, next) => { try { const keys = pickStringArray(req.body.targetKey) .map((key) => sanitizePackKey(key)) .filter((key) => key.length > 0) if (keys.length > 0) { await deletePackKeys(keys) } res.redirect('/op/dashboard') } catch (error) { next(error) } }) opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } const releases = await fetchReleaseVersions() res.render('op/editor', { userId: req.session.userId, packKey, pack: definition, releases }) } catch (error) { next(error) } }) // ─── /op/list ────────────────────────────────────────────────────────── // 음악퀴즈를 카드 한 줄로 표시. 카드 클릭 → /op/list/:packName opRouter.get('/op/list', requireAuth, async (req, res, next) => { try { const keys = await listPackKeys() const items = await Promise.all(keys.map(async (key) => ({ key, definition: await loadPackDefinition(key) }))) res.render('op/list', { userId: req.session.userId, items }) } catch (error) { next(error) } }) // 음악퀴즈 음악/사진 목록 편집 페이지. opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } const list = await loadPackList(packKey) res.render('op/listEditor', { userId: req.session.userId, packKey, pack: definition, list }) } catch (error) { next(error) } }) // 음악/사진 목록 저장. JSON body. opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') }) return } const normalized = normalizePackList(req.body) await savePackList(packKey, normalized) res.json({ ok: true }) } catch (error) { next(error) } }) // 단일 영상 메타데이터 조회 (음악 항목 수정에서 URL 변경 시 자동 갱신용). // body: { url: string } opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => { const url = pickFirstValue(req.body?.url).trim() if (!url) { res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') }) return } try { const entry = await fetchVideoMeta(url) if (!entry) { res.status(404).json({ ok: false, message: t('errors.metaNotFound') }) return } res.json({ ok: true, entry }) } catch (error) { if (error instanceof YtDlpUnavailableError) { res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' }) return } res.status(500).json({ ok: false, message: (error as Error).message }) } }) // 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환. // body: { url: string } opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => { const url = pickFirstValue(req.body?.url).trim() if (!url) { res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') }) return } try { const entries = await fetchPlaylistEntries(url) res.json({ ok: true, entries }) } catch (error) { if (error instanceof YtDlpUnavailableError) { res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' }) return } res.status(500).json({ ok: false, message: (error as Error).message }) } }) // ─── /op/datapack ────────────────────────────────────────────────────── opRouter.get('/op/datapack', requireAuth, async (req, res, next) => { try { const keys = await listPackKeys() const items = await Promise.all(keys.map(async (key) => { const definition = await loadPackDefinition(key) const list = await loadPackList(key) return { key, definition, musicCount: list.music.length } })) res.render('op/datapack', { userId: req.session.userId, items }) } catch (error) { next(error) } }) // 데이터팩 출력: list.music 으로부터 init/songs.mcfunction 본문만 만들어 // text/plain 으로 반환한다. 운영자가 mc_datapack 의 해당 파일에 붙여넣는다. opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).type('text/plain').send(t('errors.packNotFoundJson')) return } const list = await loadPackList(packKey) res.type('text/plain; charset=utf-8').send(buildSongsMcfunction(list)) } catch (error) { next(error) } }) // painting_variant JSON 들을 zip 으로 묶어 내려준다. // query.size 로 width/height (블록 단위, 기본 4, 1~16) 지정. 음악 개수만큼 cover_NN.json 생성. opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).type('text/plain').send(t('errors.packNotFoundJson')) return } const sizeRaw = Number(pickFirstValue(req.query.size)) const size = Number.isFinite(sizeRaw) && sizeRaw >= 1 && sizeRaw <= 16 ? Math.floor(sizeRaw) : 4 const list = await loadPackList(packKey) const total = list.music.length res.setHeader('Content-Type', 'application/zip') res.setHeader( 'Content-Disposition', `attachment; filename="${packKey}-painting-variants.zip"` ) const archive = archiver('zip', { zlib: { level: 9 } }) archive.on('error', (err) => next(err)) archive.pipe(res) for (let i = 1; i <= total; i++) { const nn = String(i).padStart(2, '0') const json = { asset_id: `musicquiz:cover_${nn}`, width: size, height: size, author: 'musicquiz', title: `cover_${nn}` } archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` }) } await archive.finalize() } catch (error) { next(error) } }) // ─── /op/agreement ───────────────────────────────────────────────────── // 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다. // 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만 // 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는 // /manifest/terms//index.json 으로 자신에게 표시할 약관 목록을 받는다. // /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식). opRouter.get('/op/agreement', requireAuth, async (req, res, next) => { try { const keys = await listPackKeys() const items = await Promise.all(keys.map(async (key) => ({ key, definition: await loadPackDefinition(key) }))) res.render('op/terms', { userId: req.session.userId, items }) } catch (error) { next(error) } }) // /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제. opRouter.get('/op/agreement/:packName', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } const items = await listTermsWithLabels(packKey) // 불러오기 source 후보: 현재 pack 을 제외한 나머지. const allKeys = await listPackKeys() const sourceCandidates = await Promise.all( allKeys .filter((k) => k !== packKey) .map(async (k) => ({ key: k, definition: await loadPackDefinition(k) })) ) res.render('op/terms-pack', { userId: req.session.userId, packKey, pack: definition, items, sourceCandidates }) } catch (error) { next(error) } }) opRouter.post('/op/agreement/:packName/create', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } 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(packKey, kindInput, label) res.redirect(`/op/agreement/${packKey}/${kindInput}`) } catch (error) { res.status(400).send((error as Error).message || t('terms.createFailed')) } }) opRouter.post('/op/agreement/:packName/import', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } const sourceKey = sanitizePackKey(pickFirstValue(req.body.source)) if (!sourceKey || sourceKey === packKey) { res.status(400).send(t('terms.invalidImportSource')) return } const sourceDefinition = await loadPackDefinition(sourceKey) if (!sourceDefinition) { res.status(404).send(t('terms.invalidImportSource')) return } await importTerms(packKey, sourceKey) res.redirect(`/op/agreement/${packKey}`) } catch (error) { res.status(400).send((error as Error).message || t('terms.importFailed')) } }) opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } const kind = pickFirstValue(req.params.kind) if (!isTermKind(kind)) { res.status(400).send(t('terms.invalidKind')) return } await deleteTerm(packKey, kind) res.redirect(`/op/agreement/${packKey}`) } catch (error) { next(error) } }) opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).send(t('errors.packNotFound')) return } const kind = pickFirstValue(req.params.kind) if (!isTermKind(kind)) { res.status(404).send(t('errors.unknown')) return } const entry = await getTermEntry(packKey, kind) if (!entry) { res.status(404).send(t('errors.unknown')) return } const content = await loadTerm(packKey, kind) res.render('op/termsEditor', { userId: req.session.userId, packKey, pack: definition, kind, label: entry.label, showInInstaller: entry.showInInstaller, showInInstallerRp: entry.showInInstallerRp, content }) } catch (error) { next(error) } }) opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const definition = await loadPackDefinition(packKey) if (!definition) { res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') }) return } const kind = pickFirstValue(req.params.kind) if (!isTermKind(kind)) { res.status(404).json({ ok: false, message: t('errors.unknown') }) return } const content = typeof req.body?.content === 'string' ? req.body.content : '' await saveTerm(packKey, kind, content) // visibility 토글이 함께 전송되면 동시에 갱신. 두 값이 모두 false 면 어디에도 // 표시되지 않지만 사용자가 의도적으로 선택한 결과이므로 그대로 저장한다. if ( typeof req.body?.showInInstaller === 'boolean' || typeof req.body?.showInInstallerRp === 'boolean' ) { await setTermVisibility(packKey, kind, { showInInstaller: req.body.showInInstaller === true, showInInstallerRp: req.body.showInInstallerRp === true }) } res.json({ ok: true }) } catch (error) { next(error) } }) opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => { try { const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const requestedKey = sanitizePackKey(pickFirstValue(req.body.fileName)) || packKey const platformType = pickFirstValue(req.body.platformType) const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim() const platformLoaderVersion = pickFirstValue(req.body.platformLoaderVersion).trim() const partial: Partial & Record = { name: pickFirstValue(req.body.displayName), mcVersion: pickFirstValue(req.body.mcVersion), platform: { type: (platformType as PackDefinition['platform']['type']) || 'vanilla', downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined, loaderVersion: platformLoaderVersion.length > 0 ? platformLoaderVersion : undefined } as PackDefinition['platform'] & { loaderVersion?: string }, modsFolder: pickFirstValue(req.body.modsFolder), resourcepackPath: pickFirstValue(req.body.resourcepackPath), outputPackName: pickFirstValue(req.body.outputPackName), serverMinRam: Number(pickFirstValue(req.body.serverMinRam)), serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)), clientMinRam: Number(pickFirstValue(req.body.clientMinRam)), clientRecommendedRam: Number(pickFirstValue(req.body.clientRecommendedRam)), mapPath: pickFirstValue(req.body.mapPath), serverPath: pickFirstValue(req.body.serverPath) } const normalized = normalizePackDefinition(partial) if (normalized.clientMinRam > normalized.clientRecommendedRam) { res.status(400).send(t('errors.ramOrderInvalid')) return } const finalKey = await renamePack(packKey, requestedKey, normalized) res.redirect(`/op/dashboard/${finalKey}`) } catch (error) { next(error) } })