Public Source Viewer

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

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

Redacted View
src/routes/voicepeak.routes.ts
공개 가능
1 import { Router, Request, Response } from 'express';
2 import { execFile } from 'child_process';
3 import fs from 'fs';
4 import multer from 'multer';
5 import path from 'path';
6 import { promisify } from 'util';
7 import {
8 createVoicepeakArchive,
9 createVoicepeakJob,
10 deleteAllVoicepeakJobs,
11 deleteVoicepeakJob,
12 getVoicepeakFilePath,
13 getVoicepeakJob,
14 importVoicepeakArchive,
15 listVoicepeakJobs,
16 processWaitingVoicepeakJobs,
17 startVoicepeakCacheCleanup
18 } from '../services/voicepeak.service';
19 import { recordSecurityLog } from '../services/security-log.service';
20
21 const router = Router();
22 const execFileAsync = promisify(execFile);
23 const archiveUpload = multer({
24 dest: '/tmp',
25 limits: { fileSize: 1024 * 1024 * 1024 }
26 });
27
28 startVoicepeakCacheCleanup();
29
30 function requireBinarae(req: Request, res: Response): boolean {
31 if (!req.session.username) {
32 if (req.accepts('html')) {
33 res.redirect('/login?redirect=/hinana/voicepeak');
34 } else {
35 res.status(401).json({ success: false, message: '로그인이 필요합니다.' });
36 }
37 return false;
38 }
39
40 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
41 recordSecurityLog(req, {
42 type: 'access_denied',
43 action: 'Voicepeak 접근 차단',
44 detail: '관리자 전용 Voicepeak 페이지 접근 시도'
45 });
46
47 if (req.accepts('html')) {
48 res.status(403).render('hinana/forbidden', {
49 theme: req.session.theme || req.cookies.theme || 'light',
50 title: '접근 권한이 없습니다',
51 message: '이 페이지는 관리자만 사용할 수 있습니다.'
52 });
53 } else {
54 res.status(403).json({ success: false, message: '관리자만 사용할 수 있습니다.' });
55 }
56 return false;
57 }
58
59 return true;
60 }
61
62 function requireVoicepeakAccess(req: Request, res: Response): boolean {
63 if (req.path.startsWith('/hinana/voicepeak/archive')) return true;
64 return requireBinarae(req, res);
65 }
66
67 function requireVoicepeakAccessMiddleware(req: Request, res: Response, next: () => void): void {
68 if (!requireVoicepeakAccess(req, res)) return;
69 next();
70 }
71
72 function normalizeRemoteUrl(value: unknown): string {
73 const raw = String(value || '').trim().replace(/\/+$/, '');
74 if (!raw) throw new Error('라이브 서버 URL을 입력해 주세요.');
75
76 const url = new URL(raw);
77 if (!['http:', 'https:'].includes(url.protocol)) {
78 throw new Error('http 또는 https URL만 사용할 수 있습니다.');
79 }
80
81 return url.toString().replace(/\/+$/, '');
82 }
83
84 router.get('/hinana/voicepeak', (req: Request, res: Response) => {
85 if (!requireBinarae(req, res)) return;
86
87 res.render('hinana/voicepeak', {
88 username: req.session.username,
89 theme: req.session.theme || req.cookies.theme || 'light'
90 });
91 });
92
93 router.post('/hinana/voicepeak/jobs', (req: Request, res: Response) => {
94 if (!requireBinarae(req, res)) return;
95
96 try {
97 const job = createVoicepeakJob(req.body?.sourceText || '', req.body?.title || '');
98 return res.status(202).json({ success: true, job });
99 } catch (error) {
100 return res.status(400).json({
101 success: false,
102 message: error instanceof Error ? error.message : '작업을 시작할 수 없습니다.'
103 });
104 }
105 });
106
107 router.get('/hinana/voicepeak/jobs', (req: Request, res: Response) => {
108 if (!requireBinarae(req, res)) return;
109
110 return res.json({ success: true, jobs: listVoicepeakJobs() });
111 });
112
113 function parseDateBoundary(value: unknown, endOfDay: boolean): number | undefined {
114 const raw = String(value || '').trim();
115 if (!raw) return undefined;
116
117 const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
118 if (!match) return undefined;
119
120 const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
121 if (endOfDay) date.setHours(23, 59, 59, 999);
122 return date.getTime();
123 }
124
125 router.get('/hinana/voicepeak/archive/export', async (req: Request, res: Response) => {
126 if (!requireVoicepeakAccess(req, res)) return;
127
128 try {
129 const archive = await createVoicepeakArchive({
130 from: parseDateBoundary(req.query.from, false),
131 to: parseDateBoundary(req.query.to, true)
132 });
133
134 res.download(archive.filePath, archive.fileName, (err) => {
135 fs.rmSync(archive.tempDir, { recursive: true, force: true });
136 if (err) console.error('Voicepeak archive download failed:', err.message);
137 });
138 } catch (error) {
139 res.status(400).send(error instanceof Error ? error.message : '내보내기에 실패했습니다.');
140 }
141 });
142
143 router.post('/hinana/voicepeak/archive/import', requireVoicepeakAccessMiddleware, archiveUpload.single('archive'), async (req: Request, res: Response) => {
144 if (!req.file) {
145 return res.status(400).json({ success: false, message: '가져올 파일을 선택해 주세요.' });
146 }
147
148 try {
149 const result = await importVoicepeakArchive(req.file.path);
150 return res.json({ success: true, importedCount: result.importedCount });
151 } catch (error) {
152 return res.status(400).json({
153 success: false,
154 message: error instanceof Error ? error.message : '가져오기에 실패했습니다.'
155 });
156 } finally {
157 fs.rmSync(req.file.path, { force: true });
158 }
159 });
160
161 router.post('/hinana/voicepeak/sync/pull', async (req: Request, res: Response) => {
162 if (!requireBinarae(req, res)) return;
163
164 const tempPath = path.join('/tmp', `voicepeak-pull-${Date.now()}-${Math.random().toString(36).slice(2)}.tar.gz`);
165
166 try {
167 const remoteUrl = normalizeRemoteUrl(req.body?.remoteUrl);
168
169 const params = new URLSearchParams();
170 if (req.body?.from) params.set('from', String(req.body.from));
171 if (req.body?.to) params.set('to', String(req.body.to));
172
173 const exportUrl = `${remoteUrl}/hinana/voicepeak/archive/export${params.toString() ? `?${params.toString()}` : ''}`;
174 await execFileAsync('curl', [
175 '-fsSL',
176 '-o', tempPath,
177 exportUrl
178 ], { timeout: 5 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 });
179
180 const result = await importVoicepeakArchive(tempPath);
181 return res.json({ success: true, importedCount: result.importedCount });
182 } catch (error) {
183 return res.status(400).json({
184 success: false,
185 message: error instanceof Error ? error.message : '라이브 서버에서 가져오지 못했습니다.'
186 });
187 } finally {
188 fs.rmSync(tempPath, { force: true });
189 }
190 });
191
192 router.post('/hinana/voicepeak/sync/push', async (req: Request, res: Response) => {
193 if (!requireBinarae(req, res)) return;
194
195 let archive: Awaited<ReturnType<typeof createVoicepeakArchive>> | null = null;
196
197 try {
198 const remoteUrl = normalizeRemoteUrl(req.body?.remoteUrl);
199
200 archive = await createVoicepeakArchive({
201 from: parseDateBoundary(req.body?.from, false),
202 to: parseDateBoundary(req.body?.to, true)
203 });
204
205 await execFileAsync('curl', [
206 '-fsSL',
207 '-X', 'POST',
208 '-F', `archive=@${archive.filePath};filename=${archive.fileName}`,
209 `${remoteUrl}/hinana/voicepeak/archive/import`
210 ], { timeout: 5 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 });
211
212 return res.json({ success: true, exportedCount: archive.count });
213 } catch (error) {
214 return res.status(400).json({
215 success: false,
216 message: error instanceof Error ? error.message : '라이브 서버로 보내지 못했습니다.'
217 });
218 } finally {
219 if (archive) fs.rmSync(archive.tempDir, { recursive: true, force: true });
220 }
221 });
222
223 router.post('/hinana/voicepeak/jobs/process-waiting', (req: Request, res: Response) => {
224 if (!requireBinarae(req, res)) return;
225
226 const queuedCount = processWaitingVoicepeakJobs();
227 return res.json({ success: true, queuedCount });
228 });
229
230 router.get('/hinana/voicepeak/jobs/:jobId', (req: Request, res: Response) => {
231 if (!requireBinarae(req, res)) return;
232
233 const job = getVoicepeakJob(String(req.params.jobId));
234 if (!job) {
235 return res.status(404).json({ success: false, message: '작업을 찾을 수 없습니다.' });
236 }
237
238 return res.json({ success: true, job });
239 });
240
241 router.delete('/hinana/voicepeak/jobs/:jobId', (req: Request, res: Response) => {
242 if (!requireBinarae(req, res)) return;
243
244 const deleted = deleteVoicepeakJob(String(req.params.jobId));
245 if (!deleted) {
246 return res.status(404).json({ success: false, message: '삭제할 음성을 찾을 수 없습니다.' });
247 }
248
249 return res.json({ success: true });
250 });
251
252 router.delete('/hinana/voicepeak/jobs', (req: Request, res: Response) => {
253 if (!requireBinarae(req, res)) return;
254
255 const deletedCount = deleteAllVoicepeakJobs();
256 return res.json({ success: true, deletedCount });
257 });
258
259 router.get('/hinana/voicepeak/files/:jobId/:fileName', (req: Request, res: Response) => {
260 if (!requireBinarae(req, res)) return;
261
262 const filePath = getVoicepeakFilePath(String(req.params.jobId), String(req.params.fileName));
263 if (!filePath) {
264 return res.status(404).send('파일을 찾을 수 없습니다.');
265 }
266
267 res.setHeader('Cache-Control', 'private, no-store');
268 if (path.extname(filePath) === '.mp3' && req.query.download === '1') {
269 res.download(filePath, 'koharu-rikka-voicepeak.mp3');
270 return;
271 }
272
273 res.sendFile(filePath);
274 });
275
276 export default router;
277