import { Router } from 'express' import path from 'node:path' 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 { createFolder, deleteFolder, deleteVideo, folderPath, listFolders, listVideos, loadVideoMeta, moveUploadIntoVideo, newVideoId, readAccounts, renameFolder, sanitizeFolderName, saveVideoMeta, type VideoMeta } from '../store.js' import { tmpDir } from '../paths.js' import { YtDlpUnavailableError, getJob, probeYoutube, startYoutubeDownload } from '../youtube.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' export const opRouter = Router() // 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능. // 명시적으로 무제한이 필요하면 UPLOAD_MAX_BYTES=0 (또는 Infinity). const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024 const uploadMaxBytes = (() => { const raw = process.env.UPLOAD_MAX_BYTES if (raw === undefined || raw === '') return DEFAULT_UPLOAD_MAX_BYTES const n = Number(raw) if (!Number.isFinite(n) || n <= 0) return Infinity return Math.max(1, n) })() const upload = multer({ dest: tmpDir, limits: { fileSize: uploadMaxBytes } }) function pickStr(v: unknown): string { if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '' return typeof v === 'string' ? v : '' } opRouter.get('/op', (req, res) => { if (req.session?.userId) { res.redirect('/op/dashboard') return } res.render('op/login', { error: null }) }) opRouter.post('/op', async (req, res, next) => { try { const password = pickStr(req.body.password) const accounts = await readAccounts() const matched = accounts.find((a) => a.password === password) if (!matched) { res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' }) return } req.session.userId = matched.id res.redirect('/op/dashboard') } catch (err) { next(err) } }) opRouter.post('/op/logout', (req, res) => { req.session.destroy(() => { res.redirect('/op') }) }) opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => { try { const folders = await listFolders() res.render('op/dashboard', { userId: req.session.userId, folders }) } catch (err) { next(err) } }) opRouter.post('/op/folders', requireAuth, async (req, res, next) => { try { const name = pickStr(req.body.name) const safe = await createFolder(name) res.json({ ok: true, name: safe }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) opRouter.post('/op/folders/rename', requireAuth, async (req, res) => { try { const oldName = pickStr(req.body.oldName) const newName = pickStr(req.body.newName) const result = await renameFolder(oldName, newName) res.json({ ok: true, name: result }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) opRouter.post('/op/folders/delete', requireAuth, async (req, res) => { try { const name = pickStr(req.body.name) await deleteFolder(name) res.json({ ok: true }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) opRouter.get('/op/folder/:name', requireAuth, async (req, res, next) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) { res.status(404).send('폴더를 찾을 수 없습니다.') return } try { await fs.access(folderPath(safe)) } catch { res.status(404).send('폴더를 찾을 수 없습니다.') return } const videos = await listVideos(safe) res.render('op/folder', { userId: req.session.userId, folder: safe, videos }) } catch (err) { next(err) } }) opRouter.get('/op/folder/:name/video/editor', requireAuth, async (req, res, next) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) { res.status(404).send('폴더를 찾을 수 없습니다.') return } const videoId = typeof req.query.id === 'string' ? req.query.id : null let video: VideoMeta | null = null if (videoId) { video = await loadVideoMeta(safe, videoId) } res.render('op/editor', { userId: req.session.userId, folder: safe, video }) } catch (err) { next(err) } }) opRouter.post('/op/folder/:name/video/rename', requireAuth, async (req, res) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') const id = pickStr(req.body.id) const title = pickStr(req.body.title).trim() if (!title) throw new Error('제목을 입력해 주세요.') const meta = await loadVideoMeta(safe, id) if (!meta) throw new Error('영상을 찾을 수 없습니다.') meta.title = title await saveVideoMeta(safe, meta) res.json({ ok: true }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) opRouter.post('/op/folder/:name/video/delete', requireAuth, async (req, res) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') const id = pickStr(req.body.id) await deleteVideo(safe, id) res.json({ ok: true }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } }) // 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, uploadSingle('file'), async (req, res) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') const file = req.file if (!file) throw new Error('파일이 없습니다.') const title = pickStr(req.body?.title).trim() || file.originalname const ext = (path.extname(file.originalname) || '.mp4').toLowerCase() const videoId = newVideoId() const destName = `original${ext}` await moveUploadIntoVideo(safe, videoId, file.path, destName) const now = new Date().toISOString() const meta = { id: videoId, title, originalFile: destName, editedFile: null, durationSec: null, sourceType: 'upload' as const, sourceUrl: null, trim: null, createdAt: now, updatedAt: now } await saveVideoMeta(safe, meta) res.json({ ok: true, videoId, folder: safe }) } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } } ) // 유튜브 프로브: 다운받기 전에 길이/사이즈/예상시간/5분초과경고 opRouter.post('/op/folder/:name/video/youtube/probe', requireAuth, async (req, res) => { try { const url = pickStr(req.body.url).trim() if (!url) throw new Error('URL 을 입력해 주세요.') const probe = await probeYoutube(url) res.json({ ok: true, probe }) } catch (err) { if (err instanceof YtDlpUnavailableError) { res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' }) return } res.status(400).json({ ok: false, message: (err as Error).message }) } }) opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, res) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') const url = pickStr(req.body.url).trim() const title = pickStr(req.body.title).trim() || undefined if (!url) throw new Error('URL 을 입력해 주세요.') const job = await startYoutubeDownload({ folder: safe, url, title }) res.json({ ok: true, jobId: job.id, videoId: job.videoId }) } catch (err) { if (err instanceof YtDlpUnavailableError) { res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' }) return } res.status(400).json({ ok: false, message: (err as Error).message }) } }) opRouter.get('/op/job/:id', requireAuth, (req, res) => { const job = getJob(req.params.id) if (!job) { res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' }) return } res.json({ ok: true, job }) }) // 편집 저장: trim 정보를 받아 ffmpeg 로 edited. 생성. 원본은 그대로 보존. opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => { try { const safe = sanitizeFolderName(req.params.name) if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') const id = pickStr(req.body.id) const title = pickStr(req.body.title).trim() const startSec = Number(req.body.startSec ?? 0) || 0 const endRaw = req.body.endSec const endSec = endRaw === null || endRaw === undefined || endRaw === '' ? null : Number(endRaw) const meta = await loadVideoMeta(safe, id) if (!meta) throw new Error('영상을 찾을 수 없습니다.') if (title) meta.title = title meta.trim = { startSec, endSec: endSec == null || Number.isNaN(endSec) ? null : endSec } await saveVideoMeta(safe, meta) // ffmpeg 가 없으면 trim 정보만 저장하고 안내. try { const outName = await applyTrimToVideo(safe, id, meta.trim) res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' }) } catch (err) { if (err instanceof FfmpegUnavailableError) { res.json({ ok: true, editedFile: null, note: 'ffmpeg 가 설치되지 않아 편집본을 만들지 못했습니다. trim 설정만 저장됐습니다.' }) return } throw err } } catch (err) { res.status(400).json({ ok: false, message: (err as Error).message }) } })