Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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