- pack.finalResourcepackPath 추가(/file/resourcepacks/outputs/, "."=없음, 기본 ".").
편집기에 "최종 리소스팩 (.zip)" 입력 필드 추가.
- 약관 표시대상에 showInFinalResourcepack("최종 리소스팩에 표시") 3번째 토글 추가
(스키마/시드/마이그레이션/편집기 체크박스·배지·저장 라우트, index.json 노출).
- 간편설치기 완료 단계: 기존 "직접 적용" 경고 제거 → 최종 리소스팩이 "."이 아니면
"리소스팩을 설치하시겠습니까?" 예/아니요. 아니요→완료, 예→최종 리소스팩 약관
(showInFinalResourcepack) 동의→/file/resourcepacks/outputs/ 에서 다운로드→완료.
"." 이면 이 페이지 자체를 건너뜀.
- 0.4.15→0.4.16.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
624 lines
22 KiB
TypeScript
624 lines
22 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,
|
|
readManifest,
|
|
renamePack,
|
|
sanitizePackKey,
|
|
saveTerm,
|
|
savePackList,
|
|
setPackPublic,
|
|
setTermVisibility,
|
|
writeAccounts,
|
|
SUPPORTED_JDK_MAJORS
|
|
} from '../../shared/store.js'
|
|
import { hashPassword, isHashed, verifyPassword } from '../password.js'
|
|
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
|
import { fetchJdkAvailability, fetchJdkReleaseNames } from '../../shared/jdkVersions.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()
|
|
|
|
// 로그인 브루트포스 + scrypt CPU 남용 방지용 IP 기준 인메모리 실패 제한.
|
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000
|
|
const LOGIN_MAX_FAILS = 10
|
|
const loginFails = new Map<string, { count: number; first: number; blockedUntil: number }>()
|
|
|
|
function loginClientKey(req: { ip?: string; socket?: { remoteAddress?: string } }): string {
|
|
return req.ip || req.socket?.remoteAddress || 'unknown'
|
|
}
|
|
|
|
/** 차단 중이면 남은 ms, 아니면 0. 접근 시 만료된 항목은 정리. */
|
|
function loginBlockedMs(key: string): number {
|
|
const now = Date.now()
|
|
const entry = loginFails.get(key)
|
|
if (!entry) return 0
|
|
if (entry.blockedUntil > now) return entry.blockedUntil - now
|
|
if (now - entry.first > LOGIN_WINDOW_MS) loginFails.delete(key)
|
|
return 0
|
|
}
|
|
|
|
function recordLoginFail(key: string): void {
|
|
const now = Date.now()
|
|
let entry = loginFails.get(key)
|
|
if (!entry || now - entry.first > LOGIN_WINDOW_MS) entry = { count: 0, first: now, blockedUntil: 0 }
|
|
entry.count += 1
|
|
if (entry.count >= LOGIN_MAX_FAILS) entry.blockedUntil = now + LOGIN_WINDOW_MS
|
|
loginFails.set(key, entry)
|
|
// 맵 무한 성장 방지(분산 시도 대비): 상한 초과 시 만료 항목 정리.
|
|
if (loginFails.size > 5000) {
|
|
for (const [k, v] of loginFails) {
|
|
if (v.blockedUntil <= now && now - v.first > LOGIN_WINDOW_MS) loginFails.delete(k)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 clientKey = loginClientKey(req)
|
|
const blockedMs = loginBlockedMs(clientKey)
|
|
if (blockedMs > 0) {
|
|
res.status(429).render('op/login', {
|
|
error: t('login.tooManyAttempts', { minutes: Math.ceil(blockedMs / 60000) })
|
|
})
|
|
return
|
|
}
|
|
const password = pickFirstValue(req.body.password)
|
|
const accounts = await readAccounts()
|
|
const matched = accounts.find((entry) => verifyPassword(password, entry.password))
|
|
if (!matched) {
|
|
recordLoginFail(clientKey)
|
|
res.status(401).render('op/login', { error: t('login.wrongPassword') })
|
|
return
|
|
}
|
|
loginFails.delete(clientKey)
|
|
// 평문으로 저장돼 있던 비밀번호는 로그인 성공 시 scrypt 해시로 자동 업그레이드.
|
|
if (!isHashed(matched.password)) {
|
|
try {
|
|
matched.password = hashPassword(password)
|
|
await writeAccounts(accounts)
|
|
} catch {
|
|
// 업그레이드 실패는 로그인 자체에 영향 주지 않음.
|
|
}
|
|
}
|
|
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()
|
|
const manifest = await readManifest()
|
|
const entry = manifest.packs.find((e) => e.file === packKey)
|
|
const isPublic = entry ? entry.public !== false : true
|
|
res.render('op/editor', {
|
|
userId: req.session.userId,
|
|
packKey,
|
|
pack: definition,
|
|
releases,
|
|
isPublic,
|
|
jdkOptions: SUPPORTED_JDK_MAJORS
|
|
})
|
|
} catch (error) {
|
|
next(error)
|
|
}
|
|
})
|
|
|
|
// ─── /op/jdk-versions ───────────────────────────────────────────────────
|
|
// Adoptium(Temurin) 에서 실제 배포되는 JDK 메이저 목록을 프록시(브라우저 직접 호출은
|
|
// CORS 로 막힘). 편집기의 "권장 JDK" 드롭다운을 이 결과로 채운다.
|
|
opRouter.get('/op/jdk-versions', requireAuth, async (_req, res, next) => {
|
|
try {
|
|
res.json(await fetchJdkAvailability())
|
|
} catch (error) {
|
|
next(error)
|
|
}
|
|
})
|
|
|
|
// 특정 메이저의 상세 릴리스 이름(GA + EA 스냅샷). "자세히 보기" 에서 사용.
|
|
opRouter.get('/op/jdk-versions/:major', requireAuth, async (req, res, next) => {
|
|
try {
|
|
const major = Number(req.params.major)
|
|
if (!Number.isInteger(major) || major < 8 || major > 99) {
|
|
res.status(400).json({ error: 'invalid major' })
|
|
return
|
|
}
|
|
res.json(await fetchJdkReleaseNames(major))
|
|
} 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,
|
|
showInFinalResourcepack: entry.showInFinalResourcepack,
|
|
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'
|
|
|| typeof req.body?.showInFinalResourcepack === 'boolean'
|
|
) {
|
|
await setTermVisibility(packKey, kind, {
|
|
showInInstaller: req.body.showInInstaller === true,
|
|
showInInstallerRp: req.body.showInInstallerRp === true,
|
|
showInFinalResourcepack: req.body.showInFinalResourcepack === 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),
|
|
finalResourcepackPath: pickFirstValue(req.body.finalResourcepackPath),
|
|
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)),
|
|
recommendedJdk: Number(pickFirstValue(req.body.recommendedJdk)),
|
|
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)
|
|
// 체크박스는 체크됐을 때만 전송되므로, 없으면 비공개(개발자용).
|
|
const isPublic = pickFirstValue(req.body.isPublic) === 'on' || pickFirstValue(req.body.isPublic) === 'true'
|
|
await setPackPublic(finalKey, isPublic)
|
|
res.redirect(`/op/dashboard/${finalKey}`)
|
|
} catch (error) {
|
|
next(error)
|
|
}
|
|
})
|