diff --git a/.gitignore b/.gitignore index 39f148d..b34c445 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +bin/yt-dlp +bin/yt-dlp.exe data/folders/* !data/folders/.gitkeep data/jobs/* diff --git a/README.md b/README.md index cbd8fd2..e00c210 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,31 @@ ## 실행 +처음 한 번은 `setup` 으로 의존성 + yt-dlp 바이너리 + 빌드까지 한 번에 끝낼 수 있어요: + +```bash +npm run setup # npm install + ./bin/yt-dlp 다운로드 + tsc +npm start # 기본 http://127.0.0.1:3000 (PORT=3000, HOST=127.0.0.1) +``` + +수동으로 단계별로 하고 싶다면: + ```bash npm install npm run build -npm start # 기본 http://127.0.0.1:3000 (PORT=3000, HOST=127.0.0.1) +npm start ``` - 외부 노출이 필요하면 `HOST=0.0.0.0 npm start` - 관리자 비밀번호는 `account.json` 의 `password` 값 (초기값 `admin`, 운영 시 반드시 변경) - 세션 비밀은 `SESSION_SECRET` 환경변수로 덮어쓰기 권장 +- 업로드 용량 한도: 기본 무제한. 제한하려면 `UPLOAD_MAX_BYTES=<바이트>` 설정 +- 대용량 업로드용 HTTP 요청 타임아웃: 기본 무제한(0). 필요시 `HTTP_REQUEST_TIMEOUT_MS=<밀리초>` ## 외부 의존 -- `yt-dlp` — YouTube 영상 가져오기 (`PATH` 또는 `./bin/yt-dlp` 에 설치) -- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다. +- `yt-dlp` — YouTube 영상 가져오기. `npm run setup` 이 `./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. +- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다. `npm run setup` 이 설치 여부를 검사해 안내 메시지를 출력합니다. ## 데이터 위치 diff --git a/package.json b/package.json index ec28d7d..8d571cb 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "dist/app.js", "scripts": { + "setup": "node scripts/setup.mjs", "build": "tsc -p tsconfig.json", "start": "node dist/app.js", "dev": "tsc -p tsconfig.json && node dist/app.js" diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..9888faf --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// 한 번에 의존성 설치 + yt-dlp 바이너리 다운로드 + TypeScript 빌드까지 수행한다. +// 사용: `npm run setup` +// +// yt-dlp 는 GitHub Releases 에서 현재 OS/arch 용 바이너리를 받아 ./bin/yt-dlp(.exe) 로 둔다. +// src/youtube.ts 가 이 경로를 우선 탐색하므로 PATH 설치 없이도 동작한다. +// ffmpeg 는 시스템 패키지라 자동 설치하지 않고, 없으면 안내만 출력한다. + +import { spawn, spawnSync } from 'node:child_process' +import { promises as fs, createWriteStream } from 'node:fs' +import path from 'node:path' +import https from 'node:https' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const projectRoot = path.resolve(path.dirname(__filename), '..') +const binDir = path.join(projectRoot, 'bin') + +function log(msg) { console.log(`[setup] ${msg}`) } +function warn(msg) { console.warn(`[setup] ${msg}`) } + +function ytDlpAssetName() { + if (process.platform === 'win32') return 'yt-dlp.exe' + if (process.platform === 'darwin') return 'yt-dlp_macos' + if (process.platform === 'linux') { + if (process.arch === 'arm64') return 'yt-dlp_linux_aarch64' + if (process.arch === 'arm') return 'yt-dlp_linux_armv7l' + return 'yt-dlp_linux' + } + return 'yt-dlp' +} + +function ytDlpLocalPath() { + return path.join(binDir, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp') +} + +// 리다이렉트 먼저 따라간 뒤, 최종 200 응답이 확인된 시점에만 파일을 연다. +// 이렇게 안 하면 redirect 단계에서 만들어둔 빈 writestream 이 dest 를 잡고 있어 +// 다운로드 직후 spawn 호출이 Linux 의 ETXTBSY 로 실패한다. +function fetchFollowingRedirects(url, depth = 0) { + return new Promise((resolve, reject) => { + if (depth > 5) return reject(new Error('redirect 너무 많음')) + https.get(url, { headers: { 'user-agent': 'make-video-site-setup' } }, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume() + fetchFollowingRedirects(res.headers.location, depth + 1).then(resolve, reject) + return + } + if (res.statusCode !== 200) { + res.resume() + reject(new Error(`HTTP ${res.statusCode} for ${url}`)) + return + } + resolve(res) + }).on('error', reject) + }) +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + fetchFollowingRedirects(url).then((res) => { + const file = createWriteStream(dest) + const onErr = (err) => { + file.close() + fs.unlink(dest).catch(() => undefined) + reject(err) + } + res.on('error', onErr) + file.on('error', onErr) + file.on('finish', () => file.close(() => resolve())) + res.pipe(file) + }, reject) + }) +} + +async function ensureYtDlp() { + const target = ytDlpLocalPath() + // 이미 있으면 --version 으로 점검 + try { + await fs.access(target) + const r = spawnSync(target, ['--version']) + if (r.status === 0) { + log(`yt-dlp 이미 설치됨 (${target}, version ${String(r.stdout).trim()})`) + return + } + warn(`기존 ${target} 실행 실패 — 다시 받습니다.`) + } catch { /* 없으면 그냥 진행 */ } + + await fs.mkdir(binDir, { recursive: true }) + const asset = ytDlpAssetName() + const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}` + log(`yt-dlp 다운로드 시작: ${url}`) + await downloadFile(url, target) + if (process.platform !== 'win32') { + await fs.chmod(target, 0o755) + } + // 갓 닫은 실행 파일이 즉시 spawn 되면 일부 환경에서 ETXTBSY/ENOEXEC 가 나는 경우가 있어 + // 짧게 백오프하며 재시도한다. + let version = null + let lastErr = null + for (let attempt = 0; attempt < 5; attempt++) { + if (attempt > 0) await new Promise((r) => setTimeout(r, 200 * attempt)) + const r = spawnSync(target, ['--version']) + if (r.status === 0) { + version = String(r.stdout).trim() + break + } + lastErr = r.error || new Error(`exit ${r.status}, stderr: ${String(r.stderr).trim()}`) + } + if (!version) { + throw new Error(`yt-dlp 설치는 끝났지만 실행이 실패했습니다 (${target}): ${lastErr?.message || lastErr}`) + } + log(`yt-dlp 설치 완료: ${target} (version ${version})`) +} + +function checkFfmpeg() { + const r = spawnSync('ffmpeg', ['-version']) + if (r.status === 0) { + const firstLine = String(r.stdout).split('\n')[0] + log(`ffmpeg 확인됨: ${firstLine}`) + return + } + warn('ffmpeg 가 PATH 에 없습니다. 영상 trim 저장이 필요하면 다음 중 하나로 설치해 주세요:') + warn(' Ubuntu/Debian: sudo apt-get install -y ffmpeg') + warn(' macOS: brew install ffmpeg') + warn(' Windows: winget install Gyan.FFmpeg 또는 https://www.gyan.dev/ffmpeg/builds/') +} + +function runCmd(cmd, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${cmd} ${args.join(' ')} 실패 (code=${code})`)) + }) + }) +} + +async function main() { + log(`프로젝트 루트: ${projectRoot}`) + + // npm 의존성 — postinstall 로도 들어올 수 있으므로 SKIP 옵션 제공. + if (process.env.SKIP_NPM_INSTALL !== '1') { + log('npm install 실행') + await runCmd('npm', ['install', '--no-audit', '--no-fund'], { cwd: projectRoot }) + } else { + log('SKIP_NPM_INSTALL=1 → npm install 생략') + } + + await ensureYtDlp() + checkFfmpeg() + + log('TypeScript 빌드') + await runCmd('npx', ['tsc', '-p', 'tsconfig.json'], { cwd: projectRoot }) + + log('완료. `npm start` 로 서버를 띄울 수 있습니다.') +} + +main().catch((err) => { + console.error('[setup] 실패:', err.message || err) + process.exit(1) +}) diff --git a/src/app.ts b/src/app.ts index 19e616c..b9e0725 100644 --- a/src/app.ts +++ b/src/app.ts @@ -72,10 +72,14 @@ async function main(): Promise { res.status(500).send(`서버 오류: ${message}`) }) - app.listen(PORT, HOST, () => { + const server = app.listen(PORT, HOST, () => { console.log(`[server] http://${HOST}:${PORT}`) console.log(`[server] views: ${path.relative(process.cwd(), viewsDir)}`) }) + // 10GB 같은 큰 영상 업로드가 Node 의 기본 requestTimeout(5분) 에 걸려 끊기는 걸 막는다. + // 0 = 무제한. 필요하면 HTTP_REQUEST_TIMEOUT_MS 환경변수로 조정. + server.requestTimeout = Number(process.env.HTTP_REQUEST_TIMEOUT_MS ?? 0) + // 헤더 단계 타임아웃은 짧게 유지(기본 60초). 0 으로 해버리면 slowloris 류에 취약. } main().catch((err) => { diff --git a/src/routes/op.ts b/src/routes/op.ts index 79356ec..3b59932 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import path from 'node:path' -import multer from 'multer' +import multer, { MulterError } from 'multer' +import type { Request, Response, NextFunction } from 'express' import { promises as fs } from 'node:fs' import { requireAuth } from '../auth.js' import { @@ -30,9 +31,13 @@ import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' export const opRouter = Router() +// 업로드 용량 상한은 환경변수 UPLOAD_MAX_BYTES 로 조정. 기본은 사실상 무제한(Infinity). +const uploadMaxBytes = process.env.UPLOAD_MAX_BYTES + ? Math.max(1, Number(process.env.UPLOAD_MAX_BYTES)) + : Infinity const upload = multer({ dest: tmpDir, - limits: { fileSize: 4 * 1024 * 1024 * 1024 } // 4GB + limits: { fileSize: uploadMaxBytes } }) function pickStr(v: unknown): string { @@ -181,11 +186,31 @@ opRouter.post('/op/folder/:name/video/delete', requireAuth, async (req, res) => } }) +// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해 +// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다. +function uploadSingle(fieldName: string) { + const mw = upload.single(fieldName) + return (req: Request, res: Response, next: NextFunction) => { + mw(req, res, (err: unknown) => { + if (!err) return next() + if (err instanceof MulterError) { + const message = + err.code === 'LIMIT_FILE_SIZE' + ? `파일이 너무 큽니다. (한도: ${uploadMaxBytes === Infinity ? '무제한' : uploadMaxBytes + ' bytes'}; UPLOAD_MAX_BYTES 환경변수로 조정)` + : `업로드 실패: ${err.message}` + res.status(413).json({ ok: false, code: err.code, message }) + return + } + res.status(400).json({ ok: false, message: (err as Error).message || '업로드 실패' }) + }) + } +} + // 업로드: 단일 파일. multipart/form-data, fields: title, file opRouter.post( '/op/folder/:name/video/upload', requireAuth, - upload.single('file'), + uploadSingle('file'), async (req, res) => { try { const safe = sanitizeFolderName(req.params.name)