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