Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
src/routes/japanese-lounge.routes.ts
공개 가능
1
import { Router, Request, Response, NextFunction } from 'express';
2
import fs from 'fs';
3
import multer from 'multer';
4
import path from 'path';
5
import {
6
createJapaneseLoungeEntry,
7
deleteJapaneseLoungeEntry,
8
getJapaneseLoungeAudioPath,
9
getJapaneseLoungeEntry,
10
listJapaneseLoungeEntries,
11
updateJapaneseLoungeLines,
12
updateJapaneseLoungeMeta,
13
updateJapaneseLoungeTimings
14
} from '../services/japanese-lounge.service';
15
import { recordSecurityLog } from '../services/security-log.service';
16
17
const router = Router();
18
const DRAWER_PAGE_SIZE = 5;
19
const upload = multer({
20
dest: '/tmp',
21
limits: { fileSize: 150 * 1024 * 1024 },
22
fileFilter: (_req, file, cb) => {
23
const ext = path.extname(file.originalname).toLowerCase();
24
if (ext === '.mp3' || ext === '.wav') return cb(null, true);
25
return cb(new Error('MP3 또는 WAV 파일만 업로드할 수 있습니다.'));
26
}
27
});
28
29
function isBinarae(req: Request): boolean {
30
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
31
}
32
33
function requireBinarae(req: Request, res: Response, next: NextFunction): void {
34
if (isBinarae(req)) {
35
next();
36
return;
37
}
38
39
recordSecurityLog(req, {
40
type: 'access_denied',
41
action: '일본어 라운지 관리 접근 차단',
42
detail: '비나래 전용 일본어 라운지 관리 기능 접근 시도'
43
});
44
45
if (req.accepts('json')) {
46
res.status(403).json({ success: false, message: '비나래만 관리할 수 있습니다.' });
47
return;
48
}
49
50
res.status(403).render('hinana/forbidden', {
51
theme: req.session.theme || req.cookies.theme || 'light',
52
title: '접근 권한이 없습니다',
53
message: '일본어 라운지 관리는 비나래만 사용할 수 있습니다.'
54
});
55
}
56
57
router.get('/hinana/japanese-lounge', (req: Request, res: Response) => {
58
const requestedId = String(req.query.entry || '');
59
renderJapaneseLounge(req, res, requestedId);
60
});
61
62
function renderJapaneseLounge(req: Request, res: Response, requestedId = '', errorMessage = ''): void {
63
const allEntries = listJapaneseLoungeEntries();
64
const requestedPage = Number(req.query.page);
65
const totalPages = Math.max(1, Math.ceil(allEntries.length / DRAWER_PAGE_SIZE));
66
const requestedIndex = requestedId ? allEntries.findIndex((entry) => entry.id === requestedId) : -1;
67
const pageFromEntry = requestedIndex >= 0 ? Math.floor(requestedIndex / DRAWER_PAGE_SIZE) + 1 : 0;
68
const pageFromQuery = Number.isFinite(requestedPage) ? Math.floor(requestedPage) : 1;
69
const currentPage = Math.min(Math.max(pageFromEntry || pageFromQuery || 1, 1), totalPages);
70
const pageStart = (currentPage - 1) * DRAWER_PAGE_SIZE;
71
const entries = allEntries.slice(pageStart, pageStart + DRAWER_PAGE_SIZE);
72
const activeEntry = getJapaneseLoungeEntry(requestedId) || entries[0] || allEntries[0] || null;
73
74
res.render('hinana/japaneseLounge', {
75
username: req.session.username,
76
theme: req.session.theme || req.cookies.theme || 'light',
77
entries,
78
activeEntry,
79
currentPage,
80
totalPages,
81
totalEntryCount: allEntries.length,
82
canManage: isBinarae(req),
83
errorMessage,
84
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
85
});
86
}
87
88
router.post('/hinana/japanese-lounge/entries', requireBinarae, (req: Request, res: Response) => {
89
upload.single('audio')(req, res, (uploadError: any) => {
90
if (uploadError) {
91
renderJapaneseLounge(req, res, '', uploadError instanceof Error ? uploadError.message : '업로드에 실패했습니다.');
92
return;
93
}
94
95
try {
96
const entry = createJapaneseLoungeEntry({
97
title: req.body?.title || '',
98
japaneseText: req.body?.japaneseText || '',
99
koreanText: req.body?.koreanText || '',
100
uploadedFilePath: req.file?.path || '',
101
originalAudioName: req.file?.originalname || ''
102
});
103
104
res.redirect(`/hinana/japanese-lounge?entry=${encodeURIComponent(entry.id)}`);
105
} catch (error) {
106
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
107
renderJapaneseLounge(req, res, '', error instanceof Error ? error.message : '저장에 실패했습니다.');
108
}
109
});
110
});
111
112
router.patch('/hinana/japanese-lounge/entries/:entryId/timings', requireBinarae, (req: Request, res: Response) => {
113
const entry = updateJapaneseLoungeTimings(String(req.params.entryId), Array.isArray(req.body?.timings) ? req.body.timings : []);
114
if (!entry) {
115
res.status(404).json({ success: false, message: '기록을 찾을 수 없습니다.' });
116
return;
117
}
118
119
res.json({ success: true, entry });
120
});
121
122
router.patch('/hinana/japanese-lounge/entries/:entryId/lines', requireBinarae, (req: Request, res: Response) => {
123
try {
124
const entry = updateJapaneseLoungeLines(String(req.params.entryId), Array.isArray(req.body?.lines) ? req.body.lines : []);
125
if (!entry) {
126
res.status(404).json({ success: false, message: '기록을 찾을 수 없습니다.' });
127
return;
128
}
129
130
res.json({ success: true, entry });
131
} catch (error) {
132
res.status(400).json({
133
success: false,
134
message: error instanceof Error ? error.message : '문장을 저장하지 못했습니다.'
135
});
136
}
137
});
138
139
router.patch('/hinana/japanese-lounge/entries/:entryId/meta', requireBinarae, (req: Request, res: Response) => {
140
const entry = updateJapaneseLoungeMeta(String(req.params.entryId), {
141
originalAudioName: req.body?.originalAudioName
142
});
143
if (!entry) {
144
res.status(404).json({ success: false, message: '기록을 찾을 수 없습니다.' });
145
return;
146
}
147
148
res.json({ success: true, entry });
149
});
150
151
router.delete('/hinana/japanese-lounge/entries/:entryId', requireBinarae, (req: Request, res: Response) => {
152
const deleted = deleteJapaneseLoungeEntry(String(req.params.entryId));
153
if (!deleted) {
154
res.status(404).json({ success: false, message: '삭제할 기록을 찾을 수 없습니다.' });
155
return;
156
}
157
158
res.json({ success: true });
159
});
160
161
router.get('/hinana/japanese-lounge/files/:entryId/:fileName', (req: Request, res: Response) => {
162
const filePath = getJapaneseLoungeAudioPath(String(req.params.entryId), String(req.params.fileName));
163
if (!filePath) {
164
res.status(404).send('파일을 찾을 수 없습니다.');
165
return;
166
}
167
168
res.setHeader('Cache-Control', 'private, no-store');
169
res.type(path.extname(filePath).toLowerCase() === '.wav' ? 'audio/wav' : 'audio/mpeg');
170
res.sendFile(filePath);
171
});
172
173
export default router;
174