Files
minecraft_launcher/src/server/app.ts
claude-bot c2fcc2fbbf i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
2026-05-13 03:43:04 +09:00

124 lines
3.8 KiB
TypeScript

import express from 'express'
import session from 'express-session'
import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.js'
loadEnv()
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())
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
app.use((_req, res, next) => {
res.locals.t = t
res.locals.localeDict = localeDict
next()
})
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()
})
// 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용.
app.get('/file/mods/:folder/index.json', async (req, res) => {
const folder = req.params.folder
if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) {
res.status(404).json({ files: [] })
return
}
const dir = path.join(fileDirPath, 'mods', folder)
try {
const entries = await fsp.readdir(dir)
const files = entries
.filter((name) => /\.jar$/i.test(name))
.filter((name) => !name.includes('/') && !name.includes('\\'))
.sort()
res.json({ files })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
res.status(404).json({ files: [] })
return
}
throw error
}
})
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 : t('errors.unknown')
res.status(500).send(t('errors.serverError', { message }))
})
app.listen(PORT, HOST, () => {
console.log(`[server] http://${HOST}:${PORT}`)
console.log(`[server] views: ${path.relative(process.cwd(), viewsDirPath)}`)
})