Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
src/routes/community.routes.ts
공개 가능
1 import { Router, Request, Response, NextFunction } from 'express';
2 import fs from 'fs';
3 import sanitizeHtml from 'sanitize-html';
4 import { v4 as uuidv4 } from 'uuid';
5 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
6 import { readSettings } from '../services/settings.service';
7 import { getBinaraeStatus } from '../services/post.service';
8 import path from 'path';
9 import { getBookmarks, getVerifiedUsers, isVerified, getVerifiedUntil, purchaseVerificationBadge, getAllUsersBookmarks, adminAdjustBookmarks, adminBulkAdjustBookmarks, getProfileImage, setProfileImage, deleteProfileImage, spendBookmarks } from '../services/bookmark.service';
10 import { sendPushToUser } from '../services/push.service';
11 import { profileUpload } from '../config/multer.config';
12 import { uploadRateLimit } from '../middleware/security.middleware';
13 import { PUBLIC_DIR } from '../utils/paths';
14 import { PlazaMessage } from '../types/models';
15
16 const router = Router();
17
18 function requireSubwayApiEnabled(_req: Request, res: Response, next: NextFunction): void {
19 if (readSettings().isSubwayApiEnabled === false) {
20 res.status(503).json({ success: false, message: 'Subway API is disabled.' });
21 return;
22 }
23 next();
24 }
25
26 const SUBWAY_LINES = [
27 '1호선', '2호선', '3호선', '4호선', '5호선', '6호선', '7호선', '8호선', '9호선',
28 '경의중앙선', '경춘선', '수인분당선', '신분당선', '공항철도', '경강선', '서해선',
29 '우이신설선', '신림선', 'GTX-A', '인천선', '인천2호선', '김포도시철도'
30 ];
31 // realtimePosition이 실제 열차 목록을 반환하는 노선만 전체 지도에서 조회한다.
32 // 인천 1·2호선과 김포골드라인은 역/노선도만 제공하고 실시간 위치는 현재 미제공이다.
33 const SUBWAY_MAP_LINES = SUBWAY_LINES.filter((line) => ![
34 '인천선', '인천2호선', '김포도시철도'
35 ].includes(line));
36 const subwayPositionCache = new Map<string, { expiresAt: number; rows: any[] }>();
37 const subwayPositionRequests = new Map<string, Promise<any[]>>();
38 const subwayArrivalCache = new Map<string, { expiresAt: number; rows: any[] }>();
39 let subwayCoordinateCache: { expiresAt: number; rows: any[] } | null = null;
40 const SUBWAY_CACHE_TTL_MS = 5 * 1000;
41 const SUBWAY_STATION_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
42 const SUBWAY_ALERT_POLL_MS = 5 * 1000;
43 let subwayStationCache: { expiresAt: number; rows: any[] } | null = null;
44 let subwayAlertPollRunning = false;
45
46 const SUBWAY_MASTER_LINE_MAP: Record<string, string> = {
47 '경부선': '1호선', '경원선': '1호선', '경인선': '1호선', '장항선': '1호선',
48 '일산선': '3호선',
49 '과천선': '4호선', '안산선': '4호선', '진접선': '4호선',
50 '7호선(인천)': '7호선', '별내선': '8호선', '9호선(연장)': '9호선',
51 '중앙선': '경의중앙선',
52 '분당선': '수인분당선', '수인선': '수인분당선',
53 '신분당선(연장)': '신분당선', '신분당선(연장2)': '신분당선',
54 '공항철도1호선': '공항철도',
55 '수도권 광역급행철도': 'GTX-A',
56 '인천1호선': '인천선',
57 '김포골드라인': '김포도시철도',
58 '에버라인선': '용인경전철',
59 '의정부선': '의정부경전철'
60 };
61
62 type SubwayArrivalAlert = {
63 id: string;
64 username: string;
65 trainNo: string;
66 line: string;
67 targetStation: string;
68 active: boolean;
69 createdAt: string;
70 updatedAt: string;
71 notifiedAt?: string;
72 lastStatus?: string;
73 lastStationName?: string;
74 lastCheckedAt?: string;
75 lastLiveStatusKey?: string;
76 lastLiveNotifiedAt?: string;
77 };
78
79 const STATION_LINE_ALIASES: Record<string, string[]> = {
80 '1호선': ['01호선'],
81 '2호선': ['02호선'],
82 '3호선': ['03호선'],
83 '4호선': ['04호선'],
84 '5호선': ['05호선'],
85 '6호선': ['06호선'],
86 '7호선': ['07호선'],
87 '8호선': ['08호선'],
88 '9호선': ['09호선'],
89 '경의중앙선': ['경의선'],
90 '우이신설선': ['우이신설경전철']
91 };
92
93 const STATION_NAME_ALIASES: Record<string, string[]> = {
94 '서울역': ['서울'],
95 '서울': ['서울역'],
96 '이수': ['총신대입구(이수)'],
97 '총신대입구': ['총신대입구(이수)']
98 };
99
100 const SUBWAY_ID_BY_LINE: Record<string, string[]> = {
101 '1호선': ['1001'],
102 '2호선': ['1002'],
103 '3호선': ['1003'],
104 '4호선': ['1004'],
105 '5호선': ['1005'],
106 '6호선': ['1006'],
107 '7호선': ['1007'],
108 '8호선': ['1008'],
109 '9호선': ['1009'],
110 '경의중앙선': ['1063'],
111 '공항철도': ['1065'],
112 '경춘선': ['1067'],
113 '수인분당선': ['1075'],
114 '신분당선': ['1077'],
115 '경강선': ['1081'],
116 '우이신설선': ['1092'],
117 '서해선': ['1093'],
118 '신림선': ['1094'],
119 'GTX-A': ['1032'],
120 '인천선': ['1069'],
121 '인천2호선': ['1078'],
122 '김포도시철도': ['1095']
123 };
124
125 function normalizeTrainStatus(status: unknown): string {
126 const code = String(status ?? '').trim();
127 if (code === '0') return '진입';
128 if (code === '1') return '도착';
129 if (code === '2') return '출발';
130 return code || '상태 미확인';
131 }
132
133 function normalizeStationName(name: unknown): string {
134 return String(name || '').trim().replace(/\s+/g, '').replace(/역$/, '');
135 }
136
137 function stationNamesMatch(a: unknown, b: unknown): boolean {
138 return normalizeStationName(a) === normalizeStationName(b);
139 }
140
141 function normalizeArrivalStatus(status: unknown): string {
142 const code = String(status ?? '').trim();
143 if (code === '0') return '진입';
144 if (code === '1') return '도착';
145 if (code === '2') return '출발';
146 if (code === '3') return '전역 출발';
147 if (code === '4') return '전역 진입';
148 if (code === '5') return '전역 도착';
149 if (code === '99') return '운행중';
150 return code || '상태 미확인';
151 }
152
153 function normalizeTrainDirection(direction: unknown): string {
154 const code = String(direction ?? '').trim();
155 if (code === '0') return '상행/내선';
156 if (code === '1') return '하행/외선';
157 return code || '확인 불가';
158 }
159
160 function normalizeArrivalDirectionCode(direction: unknown): string {
161 const value = String(direction ?? '').trim();
162 if (value === '0' || value === '1') return value;
163 if (value.includes('상행') || value.includes('내선')) return '0';
164 if (value.includes('하행') || value.includes('외선')) return '1';
165 return '';
166 }
167
168 function normalizeSubwayTrain(row: any, lineName: string) {
169 return {
170 line: lineName,
171 subwayId: row.subwayId || '',
172 trainNo: row.trainNo || '',
173 stationName: row.statnNm || '',
174 destination: row.statnTnm || '',
175 direction: normalizeTrainDirection(row.updnLine),
176 directionCode: row.updnLine ?? '',
177 status: normalizeTrainStatus(row.trainSttus),
178 statusCode: row.trainSttus ?? '',
179 express: String(row.directAt || '0') === '1',
180 lastTrain: String(row.lstcarAt || '0') === '1',
181 receivedAt: row.recptnDt || '',
182 raw: row
183 };
184 }
185
186 function getTrainTimestamp(train: any): number {
187 const time = new Date(train.receivedAt || train.raw?.recptnDt || 0).getTime();
188 return Number.isNaN(time) ? 0 : time;
189 }
190
191 function dedupeSubwayTrains(trains: any[]): any[] {
192 const byTrain = new Map<string, any>();
193 trains.forEach((train) => {
194 // trainNo가 비어 있는 비정상 응답끼리 한 대로 합쳐지지 않도록 위치 정보를 보조 키로 쓴다.
195 const identity = train.trainNo || `${train.subwayId}:${train.stationName}:${train.directionCode}`;
196 const key = `${train.line}:${identity}`;
197 const existing = byTrain.get(key);
198 if (!existing || getTrainTimestamp(train) >= getTrainTimestamp(existing)) {
199 byTrain.set(key, train);
200 }
201 });
202 return Array.from(byTrain.values());
203 }
204
205 function sortStations(a: any, b: any): number {
206 const aCode = String(a.frCode || '').replace(/[^\d.]/g, '');
207 const bCode = String(b.frCode || '').replace(/[^\d.]/g, '');
208 const aNum = Number.parseFloat(aCode);
209 const bNum = Number.parseFloat(bCode);
210 if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aNum !== bNum) return aNum - bNum;
211 return String(a.name).localeCompare(String(b.name), 'ko');
212 }
213
214 async function fetchSubwayStations(): Promise<any[]> {
215 if (subwayStationCache && subwayStationCache.expiresAt > Date.now()) return subwayStationCache.rows;
216
217 try {
218 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
219 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
220 if (Array.isArray(cached?.rows) && Date.now() - new Date(cached.updatedAt || 0).getTime() < SUBWAY_STATION_CACHE_TTL_MS) {
221 subwayStationCache = { expiresAt: Date.now() + SUBWAY_STATION_CACHE_TTL_MS, rows: cached.rows };
222 return cached.rows;
223 }
224 }
225 } catch (err) {
226 console.error('지하철역 캐시 읽기 오류:', err);
227 }
228
229 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
230 const response = await fetch(url);
231 const raw = await response.text();
232 let data: any;
233 try {
234 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
235 } catch {
236 throw new Error('서울시 역 정보 API가 JSON이 아닌 응답을 반환했습니다.');
237 }
238 const rows = Array.isArray(data?.SearchSTNBySubwayLineInfo?.row)
239 ? data.SearchSTNBySubwayLineInfo.row.map((row: any) => ({
240 code: row.STATION_CD || '',
241 name: row.STATION_NM || '',
242 line: row.LINE_NUM || '',
243 frCode: row.FR_CODE || '',
244 nameEng: row.STATION_NM_ENG || ''
245 })).filter((row: any) => row.name && row.line)
246 : [];
247
248 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
249 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
250 subwayStationCache = { expiresAt: Date.now() + SUBWAY_STATION_CACHE_TTL_MS, rows };
251 return rows;
252 }
253
254 async function fetchSubwayStationCoordinates(): Promise<any[]> {
255 if (subwayCoordinateCache && subwayCoordinateCache.expiresAt > Date.now()) {
256 return subwayCoordinateCache.rows;
257 }
258
259 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
260 const response = await fetch(url);
261 const raw = await response.text();
262 let data: any;
263 try {
264 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
265 } catch {
266 throw new Error('서울시 역사 좌표 API가 JSON이 아닌 응답을 반환했습니다.');
267 }
268
269 const sourceRows = Array.isArray(data?.subwayStationMaster?.row)
270 ? data.subwayStationMaster.row
271 : [];
272 const seen = new Set<string>();
273 const rows = sourceRows.map((row: any) => {
274 const station = String(row.BLDN_NM || '').trim();
275 const sourceLine = String(row.ROUTE || '').trim();
276 const line = SUBWAY_MASTER_LINE_MAP[sourceLine] || sourceLine;
277 const latitude = Number.parseFloat(String(row.LAT || ''));
278 const longitude = Number.parseFloat(String(row.LOT || ''));
279 return { station, line, latitude, longitude };
280 }).filter((row: any) => {
281 if (!row.station || !row.line || !Number.isFinite(row.latitude) || !Number.isFinite(row.longitude)) return false;
282 if (row.latitude < 35 || row.latitude > 39 || row.longitude < 124 || row.longitude > 130) return false;
283 const key = `${normalizeStationName(row.station)}:${row.line}:${row.latitude.toFixed(6)}:${row.longitude.toFixed(6)}`;
284 if (seen.has(key)) return false;
285 seen.add(key);
286 return true;
287 });
288
289 if (!rows.length) throw new Error('서울시 역사 좌표 API에서 역 좌표를 찾지 못했습니다.');
290 subwayCoordinateCache = { expiresAt: Date.now() + SUBWAY_STATION_CACHE_TTL_MS, rows };
291 return rows;
292 }
293
294 function getStationsForLine(rows: any[], lineName: string): any[] {
295 const aliases = STATION_LINE_ALIASES[lineName] || [lineName];
296 const seen = new Set<string>();
297 return rows
298 .filter((row) => aliases.includes(row.line))
299 .map((row) => ({ ...row, displayLine: lineName }))
300 .filter((row) => {
301 const key = `${row.name}:${row.frCode}`;
302 if (seen.has(key)) return false;
303 seen.add(key);
304 return true;
305 })
306 .sort(sortStations);
307 }
308
309 async function fetchSubwayLinePositions(lineName: string): Promise<any[]> {
310 const cached = subwayPositionCache.get(lineName);
311 if (cached && cached.expiresAt > Date.now()) return cached.rows;
312 const pending = subwayPositionRequests.get(lineName);
313 if (pending) return pending;
314
315 // 여러 사용자가 동시에 노선도를 열어도 같은 노선 요청은 한 번만 전송한다.
316 const request = (async () => {
317 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
318 const response = await fetch(url, { signal: AbortSignal.timeout(8_000) });
319 if (!response.ok) {
320 throw new Error(`서울시 실시간 위치 API HTTP ${response.status}`);
321 }
322 const raw = await response.text();
323 let data: any;
324 try {
325 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
326 } catch {
327 throw new Error('서울시 실시간 위치 API가 JSON이 아닌 응답을 반환했습니다.');
328 }
329
330 const rows = Array.isArray(data?.realtimePositionList) ? data.realtimePositionList : [];
331 const resultCode = String(data?.RESULT?.code || data?.RESULT?.CODE || '');
332 // INFO-200은 정상적인 데이터 없음 응답이다. 그 밖의 명시적 오류는 부분 실패로 노출한다.
333 if (!rows.length && resultCode && resultCode !== 'INFO-200') {
334 throw new Error(`서울시 실시간 위치 API 오류: ${resultCode}`);
335 }
336 subwayPositionCache.set(lineName, { expiresAt: Date.now() + SUBWAY_CACHE_TTL_MS, rows });
337 return rows;
338 })();
339 subwayPositionRequests.set(lineName, request);
340 try {
341 return await request;
342 } finally {
343 subwayPositionRequests.delete(lineName);
344 }
345 }
346
347 async function fetchStationArrivals(stationName: string): Promise<any[]> {
348 const cached = subwayArrivalCache.get(stationName);
349 if (cached && cached.expiresAt > Date.now()) return cached.rows;
350
351 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
352 const response = await fetch(url);
353 const raw = await response.text();
354 let data: any;
355 try {
356 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
357 } catch {
358 throw new Error('서울시 실시간 도착 API가 JSON이 아닌 응답을 반환했습니다.');
359 }
360 const rows = Array.isArray(data?.realtimeArrivalList) ? data.realtimeArrivalList : [];
361 subwayArrivalCache.set(stationName, { expiresAt: Date.now() + SUBWAY_CACHE_TTL_MS, rows });
362 return rows;
363 }
364
365 async function fetchStationArrivalsWithAliases(stationName: string): Promise<any[]> {
366 const trimmed = String(stationName || '').trim();
367 const withoutStationSuffix = trimmed.replace(/역$/, '');
368 const candidates = Array.from(new Set([
369 trimmed,
370 withoutStationSuffix,
371 ...(STATION_NAME_ALIASES[trimmed] || []),
372 ...(STATION_NAME_ALIASES[withoutStationSuffix] || [])
373 ].filter(Boolean)));
374 const rows: any[] = [];
375 const seen = new Set<string>();
376
377 for (const candidate of candidates) {
378 const candidateRows = await fetchStationArrivals(candidate);
379 candidateRows.forEach((row) => {
380 const key = `${row.subwayId || ''}:${row.btrainNo || ''}:${row.statnNm || ''}:${row.arvlMsg2 || ''}`;
381 if (seen.has(key)) return;
382 seen.add(key);
383 rows.push(row);
384 });
385 }
386
387 return rows;
388 }
389
390 function readSubwayAlerts(): SubwayArrivalAlert[] {
391 try {
392 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
393 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
394 return Array.isArray(data) ? data : [];
395 } catch {
396 return [];
397 }
398 }
399
400 function writeSubwayAlerts(alerts: SubwayArrivalAlert[]): void {
401 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
402 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
403 }
404
405 function sanitizeTrainNo(value: unknown): string {
406 return String(value || '').trim().slice(0, 20);
407 }
408
409 async function pollSubwayArrivalAlerts(): Promise<void> {
410 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
411 subwayAlertPollRunning = true;
412
413 try {
414 const alerts = readSubwayAlerts();
415 const activeAlerts = alerts.filter((alert) => alert.active);
416 if (activeAlerts.length === 0) return;
417
418 const rowsByLine = new Map<string, any[]>();
419 for (const line of Array.from(new Set(activeAlerts.map((alert) => alert.line)))) {
420 try {
421 rowsByLine.set(line, await fetchSubwayLinePositions(line));
422 } catch (err) {
423 console.error(`지하철 알림 위치 조회 실패 (${line}):`, err);
424 rowsByLine.set(line, []);
425 }
426 }
427
428 let changed = false;
429 for (const alert of alerts) {
430 if (!alert.active) continue;
431 const rows = rowsByLine.get(alert.line) || [];
432 const row = rows.find((item) => String(item.trainNo || '').trim() === alert.trainNo);
433 const now = new Date().toISOString();
434 alert.lastCheckedAt = now;
435
436 if (!row) {
437 alert.lastStatus = '열차 위치 미확인';
438 alert.updatedAt = now;
439 changed = true;
440 continue;
441 }
442
443 const train = normalizeSubwayTrain(row, alert.line);
444 alert.lastStationName = train.stationName;
445 alert.lastStatus = `${train.stationName || '위치 미확인'} · ${train.status}`;
446 alert.updatedAt = now;
447 changed = true;
448
449 if (stationNamesMatch(train.stationName, alert.targetStation) && (train.status === '진입' || train.status === '도착' || train.status === '출발')) {
450 alert.active = false;
451 alert.notifiedAt = now;
452 const statusText = train.status === '진입'
453 ? '진입했어요'
454 : train.status === '출발'
455 ? '출발했어요'
456 : '도착했어요';
457 await sendPushToUser(alert.username, {
458 title: `${alert.trainNo} 열차 ${train.status}`,
459 body: train.status === '출발'
460 ? `열차가 도착역에서 ${statusText}. 하차 위치를 확인하세요.`
461 : `열차가 도착역에 ${statusText}. 하차하세요.`,
462 url: '/hinana/subway',
463 icon: '/image/title.png',
464 tag: `subway-arrival-${alert.id}`,
465 renotify: true,
466 silent: false,
467 requireInteraction: true
468 });
469 } else {
470 const liveStatusKey = `${train.stationName || ''}:${train.status || ''}`;
471 if (liveStatusKey && liveStatusKey !== alert.lastLiveStatusKey) {
472 alert.lastLiveStatusKey = liveStatusKey;
473 alert.lastLiveNotifiedAt = now;
474 await sendPushToUser(alert.username, {
475 title: `${alert.trainNo} 열차 추적 중`,
476 body: `${train.stationName || '위치 미확인'}역 ${train.status || '운행'} 중 · 목표역 ${alert.targetStation}`,
477 url: '/hinana/subway',
478 icon: '/image/title.png',
479 tag: `subway-alert-${alert.id}`,
480 renotify: false,
481 silent: true,
482 requireInteraction: true
483 });
484 }
485 }
486 }
487
488 if (changed) writeSubwayAlerts(alerts);
489 } finally {
490 subwayAlertPollRunning = false;
491 }
492 }
493
494 setInterval(() => {
495 pollSubwayArrivalAlerts().catch((err) => console.error('지하철 도착 알림 폴링 오류:', err));
496 }, SUBWAY_ALERT_POLL_MS).unref();
497
498 router.get('/hinana/gallery', (req: Request, res: Response) => {
499 res.render('./hinana/gallery', {
500 username: req.session.username || null,
501 theme: req.session.theme || req.cookies.theme || 'light',
502 verifiedUsers: getVerifiedUsers()
503 });
504 });
505
506 router.get('/hinana/exhibition', (req: Request, res: Response) => {
507 res.render('./hinana/exhibition', {
508 username: req.session.username || null,
509 theme: req.session.theme || req.cookies.theme || 'light'
510 });
511 });
512
513 router.get('/hinana/lounge', (req: Request, res: Response) => {
514 const settings = readSettings();
515
516 const loungeImages = ['1.png', 'train_hinana.png'];
517 const selectedImage = loungeImages[Math.floor(Math.random() * loungeImages.length)];
518
519 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
520 let posts: any[] = [];
521 if (!err) {
522 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
523 }
524
525 const binaraeStatus = getBinaraeStatus(posts);
526
527 const currentUser = req.session.username || null;
528 res.render('./hinana/lounge', {
529 username: currentUser,
530 theme: req.session.theme || req.cookies.theme || 'light',
531 binaraeStatus: binaraeStatus,
532 randomLoungeImage: selectedImage,
533 isSignupEnabled: settings.isSignupEnabled,
534 isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
535 isGptEnabled: settings.isGptEnabled,
536 bookmarks: currentUser ? getBookmarks(currentUser) : 0,
537 currentUserProfileImage: currentUser ? getProfileImage(currentUser) : null,
538 isUserVerified: currentUser ? isVerified(currentUser) : false,
539 verifiedUsers: getVerifiedUsers()
540 });
541 });
542 });
543
544 router.get('/hinana/plaza', (req: Request, res: Response) => {
545 const settings = readSettings();
546 let messages: PlazaMessage[] = [];
547
548 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
549 try {
550 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
551 const now = new Date();
552
553 messages = data.filter((msg: PlazaMessage) => {
554 const msgDate = new Date(msg.timestamp);
555 return (now.getTime() - msgDate.getTime()) < (24 * 60 * 60 * 1000);
556 });
557
558 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
559 } catch (e) { messages = []; }
560 }
561
562 messages.reverse();
563
564 const page = parseInt(req.query.page as string) || 1;
565 const limit = 20;
566 const totalMessages = messages.length;
567 const totalPages = Math.ceil(totalMessages / limit);
568
569 const startIndex = (page - 1) * limit;
570 const endIndex = startIndex + limit;
571 const paginatedMessages = messages.slice(startIndex, endIndex);
572
573 res.render('./hinana/plaza', {
574 username: req.session.username || null,
575 theme: req.session.theme || req.cookies.theme || 'light',
576 messages: paginatedMessages,
577 currentPage: page,
578 totalPages: totalPages,
579 totalMessages: totalMessages,
580 isSignupEnabled: settings.isSignupEnabled,
581 isAnonymousPostingEnabled: settings.isAnonymousPostingEnabled,
582 isGptEnabled: settings.isGptEnabled,
583 verifiedUsers: getVerifiedUsers()
584 });
585 });
586
587 router.post('/hinana/plaza/post', (req: Request, res: Response) => {
588 const { content, isAnonymous, anonymousUsername } = req.body;
589 if (!content || content.trim().length === 0) return res.redirect('back');
590
591 const username = (isAnonymous === 'true') ? `${anonymousUsername || '익명'} (광장)` : (req.session.username || 'Guest');
592
593 const newMessage: PlazaMessage = {
594 id: uuidv4(),
595 username: username,
596 content: sanitizeHtml(content, { allowedTags: [] }),
597 timestamp: new Date().toISOString()
598 };
599
600 let messages: PlazaMessage[] = [];
601 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
602 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
603 }
604 messages.push(newMessage);
605 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
606
607 res.redirect('/hinana/plaza');
608 });
609
610 router.post('/hinana/plaza/delete', (req: Request, res: Response) => {
611 const { id } = req.body;
612 const currentUser = req.session.username;
613
614 if (!currentUser) {
615 return res.status(403).send('권한이 없습니다.');
616 }
617
618 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
619 try {
620 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
621 const msgIndex = messages.findIndex(m => m.id === id);
622
623 if (msgIndex !== -1) {
624 const msg = messages[msgIndex];
625
626 if (currentUser === '비나래' || msg.username === currentUser) {
627 messages.splice(msgIndex, 1);
628 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
629 } else {
630 return res.status(403).send('삭제 권한이 없습니다.');
631 }
632 }
633 } catch (e) {
634 console.error('Plaza delete error:', e);
635 }
636 }
637
638 res.redirect('/hinana/plaza');
639 });
640
641 router.get('/hinana/shop', (req: Request, res: Response) => {
642 const currentUser = req.session.username || null;
643 res.render('./hinana/shop', {
644 username: currentUser,
645 theme: req.session.theme || req.cookies.theme || 'light',
646 bookmarks: currentUser ? getBookmarks(currentUser) : 0,
647 isUserVerified: currentUser ? isVerified(currentUser) : false,
648 verifiedUntil: currentUser ? getVerifiedUntil(currentUser) : null,
649 verifiedUsers: getVerifiedUsers(),
650 currentUserProfileImage: currentUser ? getProfileImage(currentUser) : null
651 });
652 });
653
654 router.post('/hinana/shop/buy-badge', (req: Request, res: Response) => {
655 const username = req.session.username;
656 if (!username) {
657 return res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
658 }
659
660 const result = purchaseVerificationBadge(username);
661 res.json(result);
662 });
663
664 router.get('/hinana/admin', (req: Request, res: Response) => {
665 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
666 return res.redirect('/hinana/lounge');
667 }
668
669 res.render('./hinana/admin', {
670 username: req.session.username,
671 theme: req.session.theme || req.cookies.theme || 'light',
672 usersList: getAllUsersBookmarks(),
673 verifiedUsers: getVerifiedUsers()
674 });
675 });
676
677 router.post('/hinana/admin/bookmark', (req: Request, res: Response) => {
678 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
679 return res.status(403).json({ success: false, message: '권한이 없습니다.' });
680 }
681
682 const { username, amount } = req.body;
683 if (!username || typeof amount !== 'number' || amount === 0) {
684 return res.status(400).json({ success: false, message: '잘못된 요청입니다.' });
685 }
686
687 const result = adminAdjustBookmarks(username, amount);
688
689 if (result.success && amount > 0) {
690 sendPushToUser(username, {
691 title: '책갈피 지급',
692 body: `${amount}개의 책갈피가 관리자(비나래)에 의해 지급되었어요!`,
693 url: '/hinana/lounge',
694 icon: '/image/title.png'
695 }).catch(() => {});
696 }
697
698 res.json(result);
699 });
700
701 router.post('/hinana/admin/bookmark-bulk', (req: Request, res: Response) => {
702 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
703 return res.status(403).json({ success: false, message: '권한이 없습니다.' });
704 }
705
706 const { amount } = req.body;
707 if (typeof amount !== 'number' || amount === 0) {
708 return res.status(400).json({ success: false, message: '잘못된 요청입니다.' });
709 }
710
711 const result = adminBulkAdjustBookmarks(amount);
712
713 if (result.success && amount > 0) {
714 result.results.forEach(({ username: u }) => {
715 sendPushToUser(u, {
716 title: '책갈피 지급',
717 body: `${amount}개의 책갈피가 관리자(비나래)에 의해 지급되었어요!`,
718 url: '/hinana/lounge',
719 icon: '/image/title.png'
720 }).catch(() => {});
721 });
722 }
723
724 res.json(result);
725 });
726
727 const PROFILE_PIC_COST = 5;
728
729 router.post('/hinana/shop/buy-profile-pic', uploadRateLimit, (req: Request, res: Response, next) => {
730 if (!req.session.username) return res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
731 next();
732 }, profileUpload.single('profileImage'), (req: Request, res: Response) => {
733 const username = req.session.username!;
734 if (!req.file) {
735 return res.status(400).json({ success: false, message: '이미지를 선택해주세요.' });
736 }
737
738 const spendResult = spendBookmarks(username, PROFILE_PIC_COST);
739 if (!spendResult.success) {
740 fs.unlinkSync(req.file.path);
741 return res.json({ success: false, message: `책갈피가 부족합니다. (보유: ${spendResult.remaining}개, 필요: ${PROFILE_PIC_COST}개)` });
742 }
743
744 const imagePath = `/uploads/profiles/${req.file.filename}`;
745 const result = setProfileImage(username, imagePath);
746
747 if (result.oldImage) {
748 const oldFilePath = path.join(PUBLIC_DIR, result.oldImage);
749 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
750 }
751
752 return res.json({ success: true, message: '프로필 사진이 설정되었습니다!', remaining: spendResult.remaining, profileImage: imagePath });
753 });
754
755 router.post('/hinana/shop/delete-profile-pic', (req: Request, res: Response) => {
756 const username = req.session.username;
757 if (!username) return res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
758
759 const result = deleteProfileImage(username);
760 if (result.oldImage) {
761 const oldFilePath = path.join(PUBLIC_DIR, result.oldImage);
762 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
763 }
764
765 return res.json({ success: true, message: '프로필 사진이 삭제되었습니다.' });
766 });
767
768 router.post('/hinana/admin/delete-profile-pic', (req: Request, res: Response) => {
769 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
770 return res.status(403).json({ success: false, message: '권한이 없습니다.' });
771 }
772
773 const { username } = req.body;
774 if (!username) return res.status(400).json({ success: false, message: '잘못된 요청입니다.' });
775
776 const result = deleteProfileImage(username);
777 if (result.oldImage) {
778 const oldFilePath = path.join(PUBLIC_DIR, result.oldImage);
779 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
780 }
781
782 return res.json({ success: true, message: `${username}의 프로필 사진이 삭제되었습니다.` });
783 });
784
785 router.get('/hinana/image', (req: Request, res: Response) => {
786 res.render('./hinana/image', {
787 username: req.session.username || null,
788 theme: req.session.theme || req.cookies.theme || 'light',
789 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
790 });
791 });
792
793 router.get('/hinana/echo', (req: Request, res: Response) => {
794 res.render('./hinana/echo', {
795 username: req.session.username || null,
796 theme: req.session.theme || req.cookies.theme || 'light'
797 });
798 });
799
800 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
801 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
802 username: req.session.username || null,
803 theme: req.session.theme || req.cookies.theme || 'light'
804 });
805 });
806
807 router.get('/hinana/subway', (req: Request, res: Response) => {
808 res.render('./hinana/subway', {
809 username: req.session.username || null,
810 theme: req.session.theme || req.cookies.theme || 'light',
811 subwayLines: SUBWAY_LINES,
812 vapidPublicKey
813 });
814 });
815
816 router.get('/api/subway/stations', requireSubwayApiEnabled, async (req: Request, res: Response) => {
817 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
818 return res.status(500).json({ success: false, message: '서울시 지하철 API 키가 설정되어 있지 않습니다.' });
819 }
820
821 const line = String(req.query.line || '').trim();
822 if (!line || !SUBWAY_LINES.includes(line)) {
823 return res.status(400).json({ success: false, message: '호선을 선택해 주세요.' });
824 }
825
826 try {
827 const rows = await fetchSubwayStations();
828 return res.json({
829 success: true,
830 line,
831 stations: getStationsForLine(rows, line)
832 });
833 } catch (err) {
834 console.error('지하철역 정보 조회 오류:', err);
835 return res.status(500).json({ success: false, message: '역 정보를 불러오지 못했습니다.' });
836 }
837 });
838
839 router.get('/api/subway/train-position', requireSubwayApiEnabled, async (req: Request, res: Response) => {
840 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
841 return res.status(500).json({ success: false, message: '서울시 지하철 API 키가 설정되어 있지 않습니다.' });
842 }
843
844 const trainNo = String(req.query.trainNo || '').trim();
845 const selectedLine = String(req.query.line || '').trim();
846 if (!/^[0-9A-Za-z가-힣-]{2,20}$/.test(trainNo)) {
847 return res.status(400).json({ success: false, message: '열번을 2~20자 이내로 입력해 주세요.' });
848 }
849
850 const lines = selectedLine && SUBWAY_LINES.includes(selectedLine) ? [selectedLine] : SUBWAY_LINES;
851 const matches: any[] = [];
852 const trainsByLine: any[] = [];
853 const errors: string[] = [];
854
855 for (const line of lines) {
856 try {
857 const rows = await fetchSubwayLinePositions(line);
858 rows.forEach((row) => trainsByLine.push(normalizeSubwayTrain(row, line)));
859 rows
860 .filter((row) => String(row.trainNo || '').trim() === trainNo)
861 .forEach((row) => matches.push(normalizeSubwayTrain(row, line)));
862 } catch (err) {
863 errors.push(line);
864 }
865 }
866
867 return res.json({
868 success: true,
869 trainNo,
870 searchedLines: lines,
871 matches: dedupeSubwayTrains(matches),
872 lineTrains: selectedLine && SUBWAY_LINES.includes(selectedLine) ? dedupeSubwayTrains(trainsByLine) : [],
873 errors,
874 source: '서울시 지하철 실시간 열차 위치정보(realtimePosition)'
875 });
876 });
877
878 router.get('/hinana/subway/map', (req: Request, res: Response) => {
879 res.render('./hinana/subwayMap', {
880 username: req.session.username || null,
881 theme: req.session.theme || req.cookies.theme || 'light'
882 });
883 });
884
885 router.get('/api/subway/map-trains', requireSubwayApiEnabled, async (req: Request, res: Response) => {
886 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
887 return res.status(500).json({ success: false, message: '서울시 지하철 API 키가 설정되어 있지 않습니다.' });
888 }
889
890 const results = await Promise.allSettled(SUBWAY_MAP_LINES.map(async (line) => ({
891 line,
892 rows: await fetchSubwayLinePositions(line)
893 })));
894
895 const trains: any[] = [];
896 const errors: string[] = [];
897 results.forEach((result, index) => {
898 if (result.status === 'fulfilled') {
899 result.value.rows.forEach((row) => trains.push(normalizeSubwayTrain(row, result.value.line)));
900 } else {
901 errors.push(SUBWAY_MAP_LINES[index]);
902 }
903 });
904
905 return res.json({
906 success: true,
907 updatedAt: new Date().toISOString(),
908 trains: dedupeSubwayTrains(trains).map(({ raw, ...rest }) => rest),
909 errors,
910 source: '서울시 지하철 실시간 열차 위치정보(realtimePosition)'
911 });
912 });
913
914 router.get('/api/subway/station-coordinates', requireSubwayApiEnabled, async (_req: Request, res: Response) => {
915 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
916 return res.status(500).json({ success: false, message: '서울시 지하철 API 키가 설정되어 있지 않습니다.' });
917 }
918
919 try {
920 const stations = await fetchSubwayStationCoordinates();
921 return res.json({ success: true, stations });
922 } catch (err) {
923 console.error('지하철역 좌표 조회 오류:', err);
924 return res.status(500).json({ success: false, message: '역 좌표를 불러오지 못했습니다.' });
925 }
926 });
927
928 router.get('/api/subway/station-arrivals', requireSubwayApiEnabled, async (req: Request, res: Response) => {
929 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
930 return res.status(500).json({ success: false, message: '서울시 지하철 API 키가 설정되어 있지 않습니다.' });
931 }
932
933 const line = String(req.query.line || '').trim();
934 const station = String(req.query.station || '').trim().slice(0, 80);
935 if (!SUBWAY_LINES.includes(line)) {
936 return res.status(400).json({ success: false, message: '호선을 선택해 주세요.' });
937 }
938 if (!station) {
939 return res.status(400).json({ success: false, message: '탑승역을 선택해 주세요.' });
940 }
941
942 try {
943 const stationRows = getStationsForLine(await fetchSubwayStations(), line);
944 if (!stationRows.some((item) => stationNamesMatch(item.name, station))) {
945 return res.status(400).json({ success: false, message: '선택한 호선의 역이 아닙니다.' });
946 }
947
948 const lineIds = SUBWAY_ID_BY_LINE[line] || [];
949 const arrivals = (await fetchStationArrivalsWithAliases(station))
950 .filter((row) => lineIds.length === 0 || lineIds.includes(String(row.subwayId || '')))
951 .map((row) => ({
952 trainNo: String(row.btrainNo || '').trim(),
953 subwayId: row.subwayId || '',
954 stationName: row.statnNm || station,
955 terminalStation: row.bstatnNm || '',
956 trainLineName: row.trainLineNm || '',
957 direction: row.updnLine || '',
958 directionCode: normalizeArrivalDirectionCode(row.updnLine),
959 status: normalizeArrivalStatus(row.arvlCd),
960 statusCode: row.arvlCd || '',
961 message: row.arvlMsg2 || '',
962 messageDetail: row.arvlMsg3 || '',
963 trainType: row.btrainSttus || '',
964 receivedAt: row.recptnDt || ''
965 }))
966 .filter((row) => row.trainNo);
967
968 return res.json({
969 success: true,
970 line,
971 station,
972 arrivals
973 });
974 } catch (err) {
975 console.error('지하철 도착 정보 조회 오류:', err);
976 return res.status(500).json({ success: false, message: '도착 정보를 불러오지 못했습니다.' });
977 }
978 });
979
980 router.get('/api/subway/alerts', requireSubwayApiEnabled, (req: Request, res: Response) => {
981 const username = req.session.username;
982 if (!username) return res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
983
984 const alerts = readSubwayAlerts()
985 .filter((alert) => alert.username === username)
986 .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
987 .slice(0, 20);
988
989 return res.json({ success: true, alerts });
990 });
991
992 router.post('/api/subway/alerts', requireSubwayApiEnabled, async (req: Request, res: Response) => {
993 const username = req.session.username;
994 if (!username) return res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
995
996 const trainNo = sanitizeTrainNo(req.body?.trainNo);
997 const line = String(req.body?.line || '').trim();
998 const targetStation = String(req.body?.targetStation || '').trim().slice(0, 80);
999 if (!/^[0-9A-Za-z가-힣-]{2,20}$/.test(trainNo)) {
1000 return res.status(400).json({ success: false, message: '열번을 2~20자 이내로 입력해 주세요.' });
1001 }
1002 if (!SUBWAY_LINES.includes(line)) {
1003 return res.status(400).json({ success: false, message: '호선을 선택해 주세요.' });
1004 }
1005 if (!targetStation) {
1006 return res.status(400).json({ success: false, message: '목표역을 선택해 주세요.' });
1007 }
1008
1009 try {
1010 const stations = getStationsForLine(await fetchSubwayStations(), line);
1011 if (!stations.some((station) => stationNamesMatch(station.name, targetStation))) {
1012 return res.status(400).json({ success: false, message: '선택한 호선의 역이 아닙니다.' });
1013 }
1014 } catch (err) {
1015 return res.status(500).json({ success: false, message: '역 정보를 확인하지 못했습니다.' });
1016 }
1017
1018 const alerts = readSubwayAlerts();
1019 alerts.forEach((alert) => {
1020 if (alert.username === username && alert.active) {
1021 alert.active = false;
1022 alert.updatedAt = new Date().toISOString();
1023 }
1024 });
1025
1026 const now = new Date().toISOString();
1027 const alert: SubwayArrivalAlert = {
1028 id: uuidv4(),
1029 username,
1030 trainNo,
1031 line,
1032 targetStation,
1033 active: true,
1034 createdAt: now,
1035 updatedAt: now
1036 };
1037 alerts.push(alert);
1038 writeSubwayAlerts(alerts);
1039 console.log(`[subway-alert] Registered: ${username} ${line} ${trainNo} -> ${targetStation}`);
1040 pollSubwayArrivalAlerts().catch((err) => console.error('지하철 도착 알림 즉시 확인 오류:', err));
1041
1042 return res.status(201).json({ success: true, alert });
1043 });
1044
1045 router.delete('/api/subway/alerts/:id', requireSubwayApiEnabled, (req: Request, res: Response) => {
1046 const username = req.session.username;
1047 if (!username) return res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
1048
1049 const alertId = String(req.params.id || '');
1050 const alerts = readSubwayAlerts();
1051 const alert = alerts.find((item) => item.id === alertId && item.username === username);
1052 if (!alert) return res.status(404).json({ success: false, message: '알림을 찾을 수 없습니다.' });
1053
1054 alert.active = false;
1055 alert.updatedAt = new Date().toISOString();
1056 writeSubwayAlerts(alerts);
1057 return res.json({ success: true, alert });
1058 });
1059
1060 router.get('/hinana/lcd', (req: Request, res: Response) => {
1061 res.render('./hinana/lcd', {
1062 username: req.session.username || null,
1063 theme: req.session.theme || req.cookies.theme || 'light'
1064 });
1065 });
1066
1067 export default router;
1068