Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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