기본 업로드 한도를 1 GiB (1073741824 바이트) 로 설정. .env 의 UPLOAD_MAX_BYTES 로 바꿀 수 있고, 0 이나 Infinity 로 두면 무제한. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
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.<ext> 생성. 원본은 그대로 보존.
|
|
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 })
|
|
}
|
|
})
|