diff --git a/.config.example.json b/.config.example.json new file mode 100644 index 0000000..4069071 --- /dev/null +++ b/.config.example.json @@ -0,0 +1,13 @@ +{ + "host": "127.0.0.1", + "port": 3000, + "mongoUri": "mongodb://172.16.0.7:27017", + "mongoDatabase": "sunrise", + "visitStore": "mongo", + "allowedOrigins": [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://localhost:5173" + ] +} diff --git a/.env.example b/.env.example deleted file mode 100644 index e321875..0000000 --- a/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# Copy to .env. Never put MongoDB credentials in VITE_* variables. -MONGODB_URI=mongodb://172.16.0.7:27017 -MONGODB_DATABASE=sunrise -HOST=127.0.0.1 -PORT=3000 -ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000,http://127.0.0.1:5173,http://localhost:5173 -# Explicit temporary development mode only; visit records are not persisted: -# VISIT_STORE=memory diff --git a/.gitignore b/.gitignore index 83257ad..d5c33ae 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ __pycache__/ # Environment secrets (keep the documented template) .env.* -!.env.example +/.config.json diff --git a/README.md b/README.md index 55428bd..516d2b6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했 관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:3000`입니다. -환경설정·개발 실행·검증 방법과 운영 한계는 [서버 설명](docs/multiplayer-server.md)을 참고하세요. 기본 MongoDB는 `172.16.0.7:27017`의 `sunrise.visitSessions`이며 `.env.example`에서 변경할 수 있습니다. +환경설정·개발 실행·검증 방법과 운영 한계는 [서버 설명](docs/multiplayer-server.md)을 참고하세요. 기본 MongoDB는 `172.16.0.7:27017`의 `sunrise.visitSessions`이며 `.config.example.json`을 `.config.json`으로 복사한 뒤 변경합니다. ## 모바일·웹 몰입형 UI (2026-10-30) diff --git a/docs/multiplayer-server.md b/docs/multiplayer-server.md index 51ef6d3..9cf985a 100644 --- a/docs/multiplayer-server.md +++ b/docs/multiplayer-server.md @@ -6,16 +6,21 @@ Node.js 22.18 이상. 단일 프로세스에서 `gwanaksan` 장소의 `gwanaksan ```powershell npm ci -# 인증이 필요하거나 주소/포트를 바꿀 때 .env.example을 .env로 복사해 편집 +Copy-Item .config.example.json .config.json +# .config.json에서 DB 주소와 허용 도메인을 편집 npm run build npm start ``` -기본 접속: `http://127.0.0.1:3000`. 기본 DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `MONGODB_URI`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.env`는 버전 관리에서 제외한다. +예제 설정의 접속 주소: `http://127.0.0.1:3000`. DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `mongoUri`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.config.json`은 버전 관리에서 제외한다. + +Linux에서는 `cp .config.example.json .config.json`으로 복사한다. 설정 파일은 작업 디렉터리와 관계없이 프로젝트 루트에서 읽으며 모든 예제 항목을 유지한다. 파일이 없거나 JSON 또는 항목 형식이 잘못되면 서버 시작을 중단한다. 서버 설정은 `.env`나 환경변수에서 읽지 않는다. 수정 후 서버를 재시작한다. `npm run verify:mongo`도 같은 파일의 `mongoUri`를 사용한다. + +운영 환경에서는 `allowedOrigins`를 실제 접속 주소의 배열(예: `["https://sunrise.example.com"]`)로 바꾸고 `visitStore`는 `"mongo"`로 유지한다. JSON에는 주석을 넣지 않는다. 개발 시 별도 터미널에서 `npm run dev:server`와 `npm run dev`를 실행한다. Vite의 `/socket.io`, `/health`, `/api` 요청은 3000 포트 서버로 전달된다. `npm run preview`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다. -`HOST` 기본값은 `127.0.0.1`이라 현재 컴퓨터에서만 접속할 수 있다. LAN/운영 배포 시 실제 바인딩 주소, `PORT`, `ALLOWED_ORIGINS`를 지정하고 TLS를 제공하는 프록시와 방화벽을 구성한다. 이번 구현은 외부 호스팅 배포까지 수행하지 않는다. 동일 방/정원을 여러 Node 프로세스에서 따로 실행하지 않는다. 프로세스별 메모리는 공유되지 않으며 현재는 단일 인스턴스 구성이다. +`host` 기본값은 `127.0.0.1`이라 현재 컴퓨터에서만 접속할 수 있다. LAN/운영 배포 시 실제 바인딩 주소, `port`, `allowedOrigins`를 지정하고 TLS를 제공하는 프록시와 방화벽을 구성한다. 이번 구현은 외부 호스팅 배포까지 수행하지 않는다. 동일 방/정원을 여러 Node 프로세스에서 따로 실행하지 않는다. 프로세스별 메모리는 공유되지 않으며 현재는 단일 인스턴스 구성이다. ## 동작 @@ -36,7 +41,7 @@ npm start 서버 시작 시 과거 기록을 자동으로 변경하지 않는다. 강제 종료 또는 DB 장애로 퇴장 기록을 남기지 못하면 `leftAt`이 null로 남을 수 있다. 이는 현재 접속 중이라는 증거가 아니다. 실제 정원은 메모리에서 계산하며 `lastSeenAt`은 마지막 확인 시각일 뿐 정확한 퇴장 시각이 아니다. 로그 무손실 보장을 위한 디스크 큐와 자동 복구 작업은 포함하지 않는다. -DB 연결 실패 시 서버 시작을 중단한다. 실행 중 기록 저장 실패 시 `/health`는 503이고 신규 입장은 막으며 기존 참여자의 이동은 계속 처리한다. 원인을 해결한 후 서버를 재시작한다. 임의로 메모리 저장소로 전환하지 않는다. 개발·테스트에서만 `VISIT_STORE=memory`를 명시하면 기록을 영구 저장하지 않는 모드로 실행한다. +DB 연결 실패 시 서버 시작을 중단한다. 실행 중 기록 저장 실패 시 `/health`는 503이고 신규 입장은 막으며 기존 참여자의 이동은 계속 처리한다. 원인을 해결한 후 서버를 재시작한다. 임의로 메모리 저장소로 전환하지 않는다. 개발·테스트에서만 `visitStore=memory`를 명시하면 기록을 영구 저장하지 않는 모드로 실행한다. ## 검증 diff --git a/package.json b/package.json index 66b3066..49a93ff 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,11 @@ "prepare:data-notices": "node scripts/prepare-data-notices.ts", "prebuild": "npm run check:buildings && npm run check:summit && npm run prepare:data-notices", "build": "tsc --noEmit && tsc -p tsconfig.server.json && vite build", - "dev:server": "node --env-file-if-exists=.env --watch server/index.ts", - "start": "node --env-file-if-exists=.env server/index.ts", + "dev:server": "node --watch server/index.ts", + "start": "node server/index.ts", "check:server": "tsc -p tsconfig.server.json", "test:load": "node scripts/load-multiplayer.ts", - "verify:mongo": "node --env-file-if-exists=.env scripts/verify-multiplayer-mongo.ts", + "verify:mongo": "node scripts/verify-multiplayer-mongo.ts", "preview": "vite preview --host 127.0.0.1", "test": "node --test tests/*.test.ts", "verify:solar": "node tests/solar-report.ts" diff --git a/scripts/verify-multiplayer-mongo.ts b/scripts/verify-multiplayer-mongo.ts index 8b9bbf1..ce0386d 100644 --- a/scripts/verify-multiplayer-mongo.ts +++ b/scripts/verify-multiplayer-mongo.ts @@ -2,10 +2,11 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { MongoClient } from 'mongodb'; import { MongoVisitStore } from '../server/visits.ts'; +import { loadServerConfig } from '../server/config.ts'; -// Isolated disposable database: never uses the application's MONGODB_DATABASE. +// Isolated disposable database: never uses the application's mongoDatabase. const database = `sunrise_verify_${randomUUID().replaceAll('-', '')}`; -const uri = process.env.MONGODB_URI ?? 'mongodb://172.16.0.7:27017'; +const uri = loadServerConfig().mongoUri; const reader = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 }); let store: MongoVisitStore | undefined; try { diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..6e13a51 --- /dev/null +++ b/server/config.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; + +export type ServerConfig = { + host: string; + port: number; + mongoUri: string; + mongoDatabase: string; + visitStore: 'mongo' | 'memory'; + allowedOrigins: string[]; +}; + +export function loadServerConfig(path: string | URL = new URL('../.config.json', import.meta.url)): ServerConfig { + let contents: string; + try { contents = readFileSync(path, 'utf8'); } + catch { throw new Error('Cannot read .config.json; copy .config.example.json to .config.json and configure the server.'); } + let value: unknown; + try { value = JSON.parse(contents); } + catch { throw new Error('Invalid JSON in .config.json'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid .config.json: expected an object'); + const config = value as Record; + const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; + for (const field of ['host', 'mongoUri', 'mongoDatabase']) { + if (!nonempty(config[field])) throw new Error(`Invalid .config.json field: ${field}`); + } + if (typeof config.port !== 'number' || !Number.isInteger(config.port) || config.port < 0 || config.port > 65535) { + throw new Error('Invalid .config.json field: port'); + } + if (config.visitStore !== 'mongo' && config.visitStore !== 'memory') throw new Error('Invalid .config.json field: visitStore'); + if (!Array.isArray(config.allowedOrigins) || !config.allowedOrigins.length || !config.allowedOrigins.every(nonempty)) { + throw new Error('Invalid .config.json field: allowedOrigins'); + } + return config as ServerConfig; +} diff --git a/server/index.ts b/server/index.ts index d3e119d..91b5a69 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,24 +1,23 @@ import { createApp } from './app.ts'; +import { loadServerConfig } from './config.ts'; import { MemoryVisitStore, MongoVisitStore, type VisitStore } from './visits.ts'; async function main() { - const port = Number(process.env.PORT ?? 3000); - if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid PORT'); - if (process.env.VISIT_STORE && !['memory', 'mongo'].includes(process.env.VISIT_STORE)) throw new Error('Invalid VISIT_STORE'); + const config = loadServerConfig(); let visitStore: VisitStore; - if (process.env.VISIT_STORE === 'memory') { - console.warn('VISIT_STORE=memory: visit history is volatile; development/tests only.'); + if (config.visitStore === 'memory') { + console.warn('visitStore=memory: visit history is volatile; development/tests only.'); visitStore = new MemoryVisitStore(); } else { - visitStore = await MongoVisitStore.connect(process.env.MONGODB_URI ?? 'mongodb://172.16.0.7:27017', process.env.MONGODB_DATABASE ?? 'sunrise'); + visitStore = await MongoVisitStore.connect(config.mongoUri, config.mongoDatabase); } const app = createApp({ visitStore, - allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',').map(origin => origin.trim()).filter(Boolean) }); + allowedOrigins: config.allowedOrigins }); const stop = () => { void app.close().catch(error => { console.error('Shutdown failed', error); process.exitCode = 1; }); }; process.once('SIGINT', stop); process.once('SIGTERM', stop); app.httpServer.once('error', error => { console.error('HTTP server failed', error); process.exitCode = 1; stop(); }); - app.httpServer.listen(port, process.env.HOST ?? '127.0.0.1', () => { - console.log(`Sunrise single-instance server listening on ${process.env.HOST ?? '127.0.0.1'}:${(app.httpServer.address() as { port: number }).port}`); + app.httpServer.listen(config.port, config.host, () => { + console.log(`Sunrise single-instance server listening on ${config.host}:${(app.httpServer.address() as { port: number }).port}`); }); } void main().catch(error => { console.error('Server startup failed; admission disabled', error); process.exitCode = 1; }); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts new file mode 100644 index 0000000..f450ffe --- /dev/null +++ b/tests/server-config.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { loadServerConfig } from '../server/config.ts'; + +test('server and Mongo verifier can load the documented JSON configuration', () => { + const example = new URL('../.config.example.json', import.meta.url); + const config = loadServerConfig(example); + assert.equal(config.port, 3000); + assert.equal(config.visitStore, 'mongo'); + assert.equal(config.mongoDatabase, 'sunrise'); + assert.ok(config.allowedOrigins.includes('http://127.0.0.1:3000')); +}); + +test('missing or invalid configuration fails without exposing its contents', () => { + const directory = mkdtempSync(join(tmpdir(), 'sunrise-config-')); + const path = join(directory, '.config.json'); + try { + assert.throws(() => loadServerConfig(path), /Cannot read/); + writeFileSync(path, '{"mongoUri":"secret",'); + assert.throws(() => loadServerConfig(path), /^Error: Invalid JSON in .config.json$/); + const example = JSON.parse(readFileSync(new URL('../.config.example.json', import.meta.url), 'utf8')); + for (const override of [{ port: '3000' }, { port: 65536 }, { host: '' }, + { allowedOrigins: 'https://example.com' }, { visitStore: 'invalid' }, { mongoUri: null }]) { + writeFileSync(path, JSON.stringify({ ...example, ...override })); + assert.throws(() => loadServerConfig(path), /Invalid .config.json field/); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } +});