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)}`) })