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 { 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