Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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