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