Step 2 (싱글/멀티): replace auto-advance with a card selection plus a "다음" button so the user can review their choice before moving on. Step 4-1 (모드 플랫폼): replace the "설치 / 건너뛰기" buttons with two cards — "권장 플랫폼 설치" and "기본 마인크래프트로 설치". Selection only records intent; the actual platform install fires in 4-2 along with mods/resourcepacks (already handled by installer:client install). Server: default HOST to 127.0.0.1 instead of 0.0.0.0 so the startup log prints a Ctrl+클릭으로 바로 열 수 있는 URL. Set HOST=0.0.0.0 externally when public exposure is needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
87 lines
2.6 KiB
TypeScript
87 lines
2.6 KiB
TypeScript
import express from 'express'
|
|
import session from 'express-session'
|
|
import path from 'node:path'
|
|
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
|
|
import { indexRouter } from './routes/index'
|
|
import { opRouter } from './routes/op'
|
|
|
|
const PORT = Number(process.env.PORT ?? 3000)
|
|
// 터미널에서 Ctrl+클릭으로 바로 열 수 있도록 기본값은 127.0.0.1.
|
|
// 외부 노출이 필요할 때만 HOST=0.0.0.0 환경변수로 덮어씀.
|
|
const HOST = process.env.HOST ?? '127.0.0.1'
|
|
|
|
const app = express()
|
|
|
|
app.set('view engine', 'ejs')
|
|
app.set('views', viewsDirPath)
|
|
app.set('trust proxy', 1)
|
|
|
|
app.use(express.urlencoded({ extended: true }))
|
|
app.use(express.json())
|
|
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
maxAge: 1000 * 60 * 60 * 8
|
|
}
|
|
}))
|
|
|
|
// account.json은 외부 노출 절대 금지.
|
|
app.use((req, res, next) => {
|
|
if (/^\/account\.json/i.test(req.path)) {
|
|
res.status(404).send('Not Found')
|
|
return
|
|
}
|
|
next()
|
|
})
|
|
|
|
app.use('/static', express.static(publicDirPath))
|
|
|
|
// 외부 노출이 필요한 정적 자원만 화이트리스트로 라우팅한다.
|
|
app.get('/manifest.json', (_req, res) => {
|
|
res.sendFile(manifestRootPath)
|
|
})
|
|
|
|
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
|
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
|
|
app.get('/manifest/:fileName', (req, res) => {
|
|
const fileName = req.params.fileName
|
|
if (!/^[a-zA-Z0-9_\-]+\.json$/.test(fileName)) {
|
|
res.status(404).send('Not Found')
|
|
return
|
|
}
|
|
res.sendFile(path.join(manifestDirPath, fileName), (err) => {
|
|
if (!err || res.headersSent) return
|
|
res.status(404).send('Not Found')
|
|
})
|
|
})
|
|
|
|
// 그 외 /manifest/ 하위 모든 요청 차단 (디렉토리 인덱스 포함).
|
|
app.use((req, res, next) => {
|
|
if (/^\/manifest\//i.test(req.path)) {
|
|
res.status(404).send('Not Found')
|
|
return
|
|
}
|
|
next()
|
|
})
|
|
|
|
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
|
|
|
|
app.use('/', indexRouter)
|
|
app.use('/', opRouter)
|
|
|
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
console.error(err)
|
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
|
res.status(500).send(`서버 오류: ${message}`)
|
|
})
|
|
|
|
app.listen(PORT, HOST, () => {
|
|
console.log(`[server] http://${HOST}:${PORT}`)
|
|
console.log(`[server] views: ${path.relative(process.cwd(), viewsDirPath)}`)
|
|
})
|