- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
124 lines
3.8 KiB
TypeScript
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)}`)
|
|
})
|