import express from 'express' import session from 'express-session' import path from 'node:path' import fs from 'node:fs' import fsp from 'node:fs/promises' import crypto from 'node:crypto' import { manifestRootPath, manifestDirPath, manifestTermsDirPath, fileDirPath, viewsDirPath, publicDirPath, projectRoot, accountFilePath, accountLocalFilePath } from '../shared/paths.js' import { ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition } from '../shared/store.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) // 리버스 프록시 뒤일 때만 켠다. 항상 켜두면 직접 노출 시 X-Forwarded-For 조작으로 // req.ip 를 위조해 로그인 rate limit 을 우회할 수 있다. 프록시 뒤라면 TRUST_PROXY=true. app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : false) // 추적되는 account.json(과거 평문 노출)을 gitignore 된 account.local.json 으로 시드한다. // 로컬 파일이 이미 있으면 건드리지 않음. 이후 계정 쓰기/자동 해시 업그레이드는 로컬 // 파일에만 반영되어, 재배포로 account.json 을 추적 해제해도 로그인이 유지된다. function seedLocalAccounts(): void { try { if (fs.existsSync(accountLocalFilePath)) return if (!fs.existsSync(accountFilePath)) return fs.copyFileSync(accountFilePath, accountLocalFilePath) fs.chmodSync(accountLocalFilePath, 0o600) } catch { // 실패해도 readAccounts 가 account.json 으로 폴백하므로 치명적이지 않음. } } seedLocalAccounts() 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() }) // 세션 시크릿: 환경변수 우선, 없으면 하드코딩(위조 위험) 대신 영구 랜덤 시크릿을 // 파일로 생성/보관한다(재시작해도 세션 유지). 파일 접근 불가 시엔 프로세스 수명 동안만 // 유효한 랜덤값으로 폴백(그래도 하드코딩보다 안전). function resolveSessionSecret(): string { const fromEnv = process.env.SESSION_SECRET if (fromEnv && fromEnv.length >= 16) return fromEnv const secretPath = path.join(projectRoot, '.session-secret') try { if (fs.existsSync(secretPath)) { const existing = fs.readFileSync(secretPath, 'utf8').trim() if (existing.length >= 16) return existing } const generated = crypto.randomBytes(32).toString('hex') fs.writeFileSync(secretPath, generated, { mode: 0o600 }) return generated } catch { return crypto.randomBytes(32).toString('hex') } } app.use(session({ secret: resolveSessionSecret(), resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', // HTTPS 전용 배포면 SESSION_COOKIE_SECURE=true 로 secure 쿠키 활성화. // HTTP 접근이 섞이면 로그인 쿠키가 안 실리므로 기본값은 false. secure: process.env.SESSION_COOKIE_SECURE === 'true', 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) }) // 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다. // 음악퀴즈(pack) 별로 manifest/terms//.md 에서 노출한다. // _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단. // // fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을 // 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1 // 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가 // 생성되는 것은 loadPackDefinition 으로 차단. // 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json. // 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이 // 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`) // 으로 필터링해서 탭을 만든다. app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => { try { const { packKey } = req.params if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) { res.status(404).json({ terms: [] }) return } const pack = await loadPackDefinition(packKey) if (!pack) { res.status(404).json({ terms: [] }) return } const terms = await listTermsWithLabels(packKey) res.json({ terms }) } catch (error) { next(error) } }) app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => { try { const { packKey, fileName } = req.params if (!isPublicTermsFile(packKey, fileName)) { res.status(404).send('Not Found') return } const pack = await loadPackDefinition(packKey) if (!pack) { res.status(404).send('Not Found') return } await ensurePackTermsDir(packKey) res.type('text/markdown; charset=utf-8') res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => { if (!err || res.headersSent) return res.status(404).send('Not Found') }) } catch (error) { next(error) } }) // 설치기에서 개별 음악퀴즈 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, next) => { 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 } // async 핸들러의 throw 는 Express4 가 잡지 못해 unhandledRejection 이 되므로 next 로 위임. next(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)}`) })