리뷰 지적 추가 반영(서버 전용). - trust proxy 를 항상 켜던 것을 TRUST_PROXY=true 일 때만 켜도록 변경. 직접 노출 시 X-Forwarded-For 조작으로 로그인 rate limit 을 우회하던 문제 차단(프록시 뒤면 TRUST_PROXY=true 설정). - account.local.json 을 0o600(소유자 전용)으로 저장. - 서버 시작 시 account.local.json 이 없으면 account.json 에서 시드(0o600). 이렇게 하면 재배포 직후(로그인 전에도) 로컬 계정 파일이 항상 존재해, 이후 account.json 을 안전하게 추적 해제할 수 있다. - account.example.json 템플릿 추가. account.json 자체의 git 추적 해제는 서버가 한 번 재배포되어 account.local.json 이 생성된 뒤 후속 커밋에서 처리(그 전에 지우면 pull 시 삭제되어 로그인이 막힘). Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
228 lines
8.2 KiB
TypeScript
228 lines
8.2 KiB
TypeScript
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/<packKey>/<file>.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)}`)
|
|
})
|