이미지 zip 의 cover_NN.json 이 title/author 를 {text:...} 객체로 내보내
일부 환경에서 인식되지 않던 문제. 요청 형식대로 author:"musicquiz",
title:"cover_NN" 평문 문자열로 바꿔 asset_id/width/height 뒤에 배치한다.
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
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/<packKey>/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<PackDefinition> & Record<string, unknown> = {
|
|
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)
|
|
}
|
|
})
|