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