From c5faa8808c58e270e653bfd05c67d480d81046b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 23:36:02 +0900 Subject: [PATCH] feat(folders): nested folder tree (max depth 2) + id-based mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new storeDb.ts: DB+FS layer (folders w/ parent_id, videos w/ global id) - depth cap: sub-folder cannot have children (enforced in createFolder) - video id: editable anytime (changeVideoId), uniqueness via PK - routes: - public /folder/:topName[/:subName], /video/:topName[/:subName]/:videoId - admin /op/folder/:topName[/:subName], id-based /op/folders/:id/* and /op/videos/:id/* (rename/changeId/delete/save + idAvailable check) - views: breadcrumb + subFolder grid; 플레이리스트 추가 button shows at 2단계 - client JS: id-based mutation endpoints, share URL data attribute, in-site player pushes share URL to address bar - store.ts slimmed to Account + readAccounts (FS layer moved to storeDb) - pickIntId accepts numeric JSON bodies Co-Authored-By: Claude Opus 4.7 --- public/dashboard.js | 48 ++-- public/editor.js | 20 +- public/folder.js | 171 ++++++++++--- public/player.js | 18 +- public/public-folder.js | 19 +- src/editor.ts | 14 +- src/routes/op.ts | 403 +++++++++++++++++------------- src/routes/public.ts | 147 +++++++---- src/store.ts | 203 +--------------- src/storeDb.ts | 524 ++++++++++++++++++++++++++++++++++++++++ src/youtube.ts | 56 ++--- views/folder.ejs | 46 ++-- views/index.ejs | 6 +- views/op/dashboard.ejs | 8 +- views/op/editor.ejs | 14 +- views/op/folder.ejs | 76 +++++- views/player.ejs | 11 +- 17 files changed, 1207 insertions(+), 577 deletions(-) create mode 100644 src/storeDb.ts diff --git a/public/dashboard.js b/public/dashboard.js index 7e93449..8ed0d5d 100644 --- a/public/dashboard.js +++ b/public/dashboard.js @@ -1,5 +1,6 @@ (function () { var ctxMenu = document.getElementById('ctxMenu') + var targetId = null var targetName = null function showCtx(x, y) { @@ -7,11 +8,12 @@ ctxMenu.style.top = y + 'px' ctxMenu.hidden = false } - function hideCtx() { ctxMenu.hidden = true; targetName = null } + function hideCtx() { ctxMenu.hidden = true; targetId = null; targetName = null } document.querySelectorAll('.adminFolder').forEach(function (card) { card.addEventListener('contextmenu', function (e) { e.preventDefault() + targetId = card.getAttribute('data-folder-id') targetName = card.getAttribute('data-name') showCtx(e.clientX, e.clientY) }) @@ -23,15 +25,15 @@ ctxMenu.addEventListener('click', function (e) { var btn = e.target.closest('button') - if (!btn) return + if (!btn || !targetId) return var action = btn.getAttribute('data-action') if (action === 'rename') { var newName = window.prompt('새 폴더 이름', targetName) if (newName && newName !== targetName) { - fetch('/op/folders/rename', { + fetch('/op/folders/' + encodeURIComponent(targetId) + '/rename', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ oldName: targetName, newName: newName }) + body: JSON.stringify({ newName: newName }) }).then(function (r) { return r.json() }).then(function (j) { if (j.ok) location.reload() else alert(j.message || '이름 변경 실패') @@ -39,10 +41,8 @@ } } else if (action === 'delete') { if (window.confirm('"' + targetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) { - fetch('/op/folders/delete', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: targetName }) + fetch('/op/folders/' + encodeURIComponent(targetId) + '/delete', { + method: 'POST' }).then(function (r) { return r.json() }).then(function (j) { if (j.ok) location.reload() else alert(j.message || '삭제 실패') @@ -61,20 +61,22 @@ function openModal() { modal.hidden = false; input.value = ''; setTimeout(function () { input.focus() }, 0) } function closeModal() { modal.hidden = true } - addBtn.addEventListener('click', openModal) - cancelBtn.addEventListener('click', closeModal) - modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() }) - input.addEventListener('keydown', function (e) { if (e.key === 'Enter') confirmBtn.click() }) - confirmBtn.addEventListener('click', function () { - var name = input.value.trim() - if (!name) return - fetch('/op/folders', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: name }) - }).then(function (r) { return r.json() }).then(function (j) { - if (j.ok) location.reload() - else alert(j.message || '폴더 생성 실패') + if (addBtn) { + addBtn.addEventListener('click', openModal) + cancelBtn.addEventListener('click', closeModal) + modal.addEventListener('click', function (e) { if (e.target === modal) closeModal() }) + input.addEventListener('keydown', function (e) { if (e.key === 'Enter') confirmBtn.click() }) + confirmBtn.addEventListener('click', function () { + var name = input.value.trim() + if (!name) return + fetch('/op/folders', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: name }) + }).then(function (r) { return r.json() }).then(function (j) { + if (j.ok) location.reload() + else alert(j.message || '폴더 생성 실패') + }) }) - }) + } })() diff --git a/public/editor.js b/public/editor.js index 7f66c4f..4913ac8 100644 --- a/public/editor.js +++ b/public/editor.js @@ -1,6 +1,7 @@ (function () { - var ctx = window.__EDITOR__ || { folder: '', video: null } - var folder = ctx.folder + var ctx = window.__EDITOR__ || { folderPath: [], folderId: null, video: null } + var folderPathEnc = (ctx.folderPath || []).map(encodeURIComponent).join('/') + var folderBaseUrl = '/op/folder/' + folderPathEnc var video = ctx.video var dropZone = document.getElementById('dropZone') @@ -46,7 +47,7 @@ form.append('title', titleInput.value || file.name) uploadStatus.textContent = '업로드 중...' var xhr = new XMLHttpRequest() - xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload') + xhr.open('POST', folderBaseUrl + '/video/upload') xhr.upload.addEventListener('progress', function (e) { if (e.lengthComputable) { var pct = Math.round((e.loaded / e.total) * 100) @@ -57,7 +58,7 @@ try { var res = JSON.parse(xhr.responseText) if (res.ok) { - location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId) + location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(res.videoId) } else { uploadStatus.textContent = '업로드 실패: ' + (res.message || '') } @@ -89,7 +90,7 @@ probeInfo.textContent = '확인 중...' ytProbeBtn.disabled = true ytStartBtn.disabled = true - fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', { + fetch(folderBaseUrl + '/video/youtube/probe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url }) @@ -144,7 +145,7 @@ } // 중복 클릭 방지 ytStartBtn.disabled = true - fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', { + fetch(folderBaseUrl + '/video/youtube/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url, title: titleInput.value }) @@ -180,7 +181,7 @@ dlProgress.value = job.progress probeInfo.textContent = job.message if (job.status === 'done') { - location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId) + location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(videoId) } else if (job.status === 'error') { probeInfo.textContent = '실패: ' + (job.error || '') } else { @@ -364,14 +365,13 @@ saveBtn.addEventListener('click', function () { if (!video) { alert('먼저 영상을 추가하세요.'); return } var payload = { - id: video.id, title: titleInput.value, startSec: trimStart, endSec: trimEnd } saveBtn.disabled = true saveBtn.textContent = '저장 중...' - fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', { + fetch('/op/videos/' + encodeURIComponent(video.id) + '/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) @@ -380,7 +380,7 @@ saveBtn.textContent = '저장' if (j.ok) { alert(j.note || '저장 완료') - location.href = '/op/folder/' + encodeURIComponent(folder) + location.href = folderBaseUrl } else { alert(j.message || '저장 실패') } diff --git a/public/folder.js b/public/folder.js index 2042d6b..f874ffb 100644 --- a/public/folder.js +++ b/public/folder.js @@ -1,74 +1,171 @@ (function () { - var folder = (window.__OP__ || {}).folder - var ctxMenu = document.getElementById('ctxMenu') - var targetId = null - var targetTitle = null + var op = window.__OP__ || { folderId: null, folderPath: [], isSubFolder: false } + var folderPathEnc = (op.folderPath || []).map(encodeURIComponent).join('/') - function showCtx(x, y) { - ctxMenu.style.left = x + 'px' - ctxMenu.style.top = y + 'px' - ctxMenu.hidden = false + // ── 영상 컨텍스트 메뉴 ─────────────────────────────────────────────── + var videoCtxMenu = document.getElementById('ctxMenu') + var videoTargetId = null + var videoTargetTitle = null + var videoTargetShareUrl = null + + function showVideoCtx(x, y) { + videoCtxMenu.style.left = x + 'px' + videoCtxMenu.style.top = y + 'px' + videoCtxMenu.hidden = false } - function hideCtx() { ctxMenu.hidden = true } + function hideVideoCtx() { videoCtxMenu.hidden = true } document.querySelectorAll('.adminVideo').forEach(function (card) { card.addEventListener('contextmenu', function (e) { e.preventDefault() - targetId = card.getAttribute('data-id') - targetTitle = card.getAttribute('data-title') - showCtx(e.clientX, e.clientY) + videoTargetId = card.getAttribute('data-id') + videoTargetTitle = card.getAttribute('data-title') + videoTargetShareUrl = card.getAttribute('data-share-url') + showVideoCtx(e.clientX, e.clientY) }) }) - document.addEventListener('click', function (e) { - if (!ctxMenu.contains(e.target)) hideCtx() - }) - - ctxMenu.addEventListener('click', function (e) { + videoCtxMenu.addEventListener('click', function (e) { var btn = e.target.closest('button') - if (!btn) return + if (!btn || !videoTargetId) return var action = btn.getAttribute('data-action') if (action === 'edit') { - location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(targetId) + location.href = '/op/folder/' + folderPathEnc + '/video/editor?id=' + encodeURIComponent(videoTargetId) } else if (action === 'copyUrl') { - copyVideoUrl(targetId) + copyUrl(location.origin + videoTargetShareUrl) } else if (action === 'rename') { - var t = window.prompt('새 영상 제목', targetTitle) - if (t && t !== targetTitle) { - fetch('/op/folder/' + encodeURIComponent(folder) + '/video/rename', { + var t = window.prompt('새 영상 제목', videoTargetTitle) + if (t && t !== videoTargetTitle) { + fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/rename', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: targetId, title: t }) + body: JSON.stringify({ title: t }) }).then(function (r) { return r.json() }).then(function (j) { if (j.ok) location.reload() else alert(j.message || '이름 변경 실패') }) } - } else if (action === 'delete') { - if (window.confirm('"' + targetTitle + '" 영상을 정말 삭제할까요?')) { - fetch('/op/folder/' + encodeURIComponent(folder) + '/video/delete', { + } else if (action === 'changeId') { + var newId = window.prompt('새 영상 ID (영문/숫자/_/-, 1~64자)', videoTargetId) + if (newId && newId !== videoTargetId) { + fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/changeId', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ id: targetId }) + body: JSON.stringify({ newId: newId }) + }).then(function (r) { return r.json() }).then(function (j) { + if (j.ok) location.reload() + else alert(j.message || 'ID 변경 실패') + }) + } + } else if (action === 'delete') { + if (window.confirm('"' + videoTargetTitle + '" 영상을 정말 삭제할까요?')) { + fetch('/op/videos/' + encodeURIComponent(videoTargetId) + '/delete', { + method: 'POST' }).then(function (r) { return r.json() }).then(function (j) { if (j.ok) location.reload() else alert(j.message || '삭제 실패') }) } } - hideCtx() + hideVideoCtx() }) - function copyVideoUrl(id) { - var url = location.origin + '/file/video/' + encodeURIComponent(id) - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(url).then(function () { - flashToast('주소가 복사되었습니다.') - }, function () { - fallbackCopy(url) + // ── 하위 폴더 컨텍스트 메뉴 (rename / delete) ──────────────────────── + var folderCtxMenu = document.getElementById('folderCtxMenu') + var folderTargetId = null + var folderTargetName = null + + function showFolderCtx(x, y) { + folderCtxMenu.style.left = x + 'px' + folderCtxMenu.style.top = y + 'px' + folderCtxMenu.hidden = false + } + function hideFolderCtx() { folderCtxMenu.hidden = true } + + document.querySelectorAll('#subFolderGrid .adminFolder').forEach(function (card) { + card.addEventListener('contextmenu', function (e) { + e.preventDefault() + folderTargetId = card.getAttribute('data-folder-id') + folderTargetName = card.getAttribute('data-name') + showFolderCtx(e.clientX, e.clientY) + }) + }) + + if (folderCtxMenu) { + folderCtxMenu.addEventListener('click', function (e) { + var btn = e.target.closest('button') + if (!btn || !folderTargetId) return + var action = btn.getAttribute('data-action') + if (action === 'rename') { + var newName = window.prompt('새 폴더 이름', folderTargetName) + if (newName && newName !== folderTargetName) { + fetch('/op/folders/' + encodeURIComponent(folderTargetId) + '/rename', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ newName: newName }) + }).then(function (r) { return r.json() }).then(function (j) { + if (j.ok) location.reload() + else alert(j.message || '이름 변경 실패') + }) + } + } else if (action === 'delete') { + if (window.confirm('"' + folderTargetName + '" 폴더를 정말 삭제할까요? 안의 영상이 모두 사라집니다.')) { + fetch('/op/folders/' + encodeURIComponent(folderTargetId) + '/delete', { + method: 'POST' + }).then(function (r) { return r.json() }).then(function (j) { + if (j.ok) location.reload() + else alert(j.message || '삭제 실패') + }) + } + } + hideFolderCtx() + }) + } + + document.addEventListener('click', function (e) { + if (videoCtxMenu && !videoCtxMenu.contains(e.target)) hideVideoCtx() + if (folderCtxMenu && !folderCtxMenu.contains(e.target)) hideFolderCtx() + }) + + // ── 하위 폴더 추가 모달 ────────────────────────────────────────────── + var addSubBtn = document.getElementById('addSubFolderBtn') + if (addSubBtn) { + var subModal = document.getElementById('addSubFolderModal') + var subInput = document.getElementById('addSubFolderInput') + var subCancel = document.getElementById('addSubFolderCancel') + var subConfirm = document.getElementById('addSubFolderConfirm') + + function openSubModal() { + subModal.hidden = false; subInput.value = '' + setTimeout(function () { subInput.focus() }, 0) + } + function closeSubModal() { subModal.hidden = true } + addSubBtn.addEventListener('click', openSubModal) + subCancel.addEventListener('click', closeSubModal) + subModal.addEventListener('click', function (e) { if (e.target === subModal) closeSubModal() }) + subInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') subConfirm.click() }) + subConfirm.addEventListener('click', function () { + var name = subInput.value.trim() + if (!name) return + fetch('/op/folders', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: name, parentId: op.folderId }) + }).then(function (r) { return r.json() }).then(function (j) { + if (j.ok) location.reload() + else alert(j.message || '폴더 생성 실패') }) + }) + } + + // ── 유틸 ────────────────────────────────────────────────────────── + function copyUrl(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { + flashToast('주소가 복사되었습니다.') + }, function () { fallbackCopy(text) }) } else { - fallbackCopy(url) + fallbackCopy(text) } } function fallbackCopy(text) { diff --git a/public/player.js b/public/player.js index a26ef6c..cd63561 100644 --- a/public/player.js +++ b/public/player.js @@ -1,13 +1,15 @@ (function () { var overlay = document.getElementById('playerOverlay') + if (!overlay) return var closeBtn = document.getElementById('playerClose') var video = document.getElementById('playerVideo') var titleEl = document.getElementById('playerTitle') - function openPlayer(videoId, title) { - // 스펙: /player/:videoId 로 이동한 것처럼 동작하면서 내부 팝업으로 띄운다. - // pushState 로 URL 만 바꿔, 새로고침/직접접근 시 player.ejs 가 응답한다. - history.pushState({ player: true, videoId: videoId }, '', '/player/' + encodeURIComponent(videoId)) + function openPlayer(videoId, title, shareUrl) { + // 사이트 내 재생도 share URL 을 주소창에 보이도록 pushState. + // share URL 이 없으면 단순한 /file/video/:id 로 폴백. + var url = shareUrl || ('/file/video/' + encodeURIComponent(videoId)) + history.pushState({ player: true, videoId: videoId }, '', url) titleEl.textContent = title || '' video.src = '/api/video/' + encodeURIComponent(videoId) + '/file' overlay.hidden = false @@ -19,17 +21,19 @@ video.pause() video.removeAttribute('src') video.load() - // 폴더 페이지로 되돌리기 if (history.state && history.state.player) { history.back() } } document.querySelectorAll('.videoCard').forEach(function (card) { + // 태그면 클릭 시 share URL 로 풀 페이지 이동하도록 두고, 모달은 띄우지 않는다. + if (card.tagName && card.tagName.toLowerCase() === 'a') return card.addEventListener('click', function () { var id = card.getAttribute('data-video-id') - var title = card.querySelector('.videoTitle') - openPlayer(id, title ? title.textContent : '') + var titleNode = card.querySelector('.videoTitle') + var shareUrl = card.getAttribute('data-share-url') + openPlayer(id, titleNode ? titleNode.textContent : '', shareUrl) }) }) diff --git a/public/public-folder.js b/public/public-folder.js index 0c3def1..a6b3b42 100644 --- a/public/public-folder.js +++ b/public/public-folder.js @@ -1,7 +1,7 @@ (function () { var ctxMenu = document.getElementById('ctxMenu') if (!ctxMenu) return - var targetId = null + var targetShareUrl = null function showCtx(x, y) { ctxMenu.style.left = x + 'px' @@ -13,7 +13,7 @@ document.querySelectorAll('.videoCard').forEach(function (card) { card.addEventListener('contextmenu', function (e) { e.preventDefault() - targetId = card.getAttribute('data-video-id') || card.getAttribute('data-id') + targetShareUrl = card.getAttribute('data-share-url') showCtx(e.clientX, e.clientY) }) }) @@ -27,22 +27,19 @@ var btn = e.target.closest('button') if (!btn) return var action = btn.getAttribute('data-action') - if (action === 'copyUrl' && targetId) { - copyVideoUrl(targetId) + if (action === 'copyUrl' && targetShareUrl) { + copyUrl(location.origin + targetShareUrl) } hideCtx() }) - function copyVideoUrl(id) { - var url = location.origin + '/file/video/' + encodeURIComponent(id) + function copyUrl(text) { if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(url).then(function () { + navigator.clipboard.writeText(text).then(function () { flashToast('주소가 복사되었습니다.') - }, function () { - fallbackCopy(url) - }) + }, function () { fallbackCopy(text) }) } else { - fallbackCopy(url) + fallbackCopy(text) } } function fallbackCopy(text) { diff --git a/src/editor.ts b/src/editor.ts index ce60f83..f03803a 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync } from 'node:child_process' import { promises as fs } from 'node:fs' import path from 'node:path' -import { videoDir, loadVideoMeta, saveVideoMeta, type VideoTrim } from './store.js' +import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js' export class FfmpegUnavailableError extends Error { constructor() { @@ -184,14 +184,13 @@ export async function upscaleOriginalTo60Fps( * stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩. */ export async function applyTrimToVideo( - folder: string, videoId: string, trim: VideoTrim ): Promise { const bin = getFfmpegPath() - const meta = await loadVideoMeta(folder, videoId) + const meta = getVideo(videoId) if (!meta) throw new Error('비디오를 찾을 수 없습니다.') - const dir = videoDir(folder, videoId) + const dir = videoDiskDir(videoId) const inputPath = path.join(dir, meta.originalFile) await fs.access(inputPath) @@ -236,9 +235,10 @@ export async function applyTrimToVideo( } await fs.rename(tmpPath, outPath) - meta.editedFile = outName - meta.trim = { startSec, endSec } - await saveVideoMeta(folder, meta) + updateVideo(videoId, { + editedFile: outName, + trim: { startSec, endSec } + }) return outName } diff --git a/src/routes/op.ts b/src/routes/op.ts index a32fcd5..43af09d 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -2,24 +2,26 @@ 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 { readAccounts } from '../store.js' import { + changeVideoId, createFolder, + createVideo, deleteFolder, deleteVideo, - folderPath, - listFolders, - listVideos, - loadVideoMeta, - moveUploadIntoVideo, - newVideoId, - readAccounts, + folderPathNames, + getFolder, + getFolderByPath, + getVideo, + listChildFolders, + listTopFolders, + listVideosInFolder, + moveUploadIntoVideoDir, renameFolder, - sanitizeFolderName, - saveVideoMeta, - type VideoMeta -} from '../store.js' + updateVideo, + videoIdExists +} from '../storeDb.js' import { tmpDir } from '../paths.js' import { YtDlpUnavailableError, @@ -32,9 +34,6 @@ import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' export const opRouter = Router() // 업로드 용량 상한. 기본 1 GiB. UPLOAD_MAX_BYTES 환경변수로 변경 가능. -// - 비어있거나 미설정이면 기본 1 GiB -// - "0" 또는 "Infinity" 만 명시적 무제한으로 인정 -// - 잘못된 값 (오타 등) 은 기본 1 GiB 로 fallback (경고 출력) const DEFAULT_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024 const uploadMaxBytes = (() => { const raw = process.env.UPLOAD_MAX_BYTES @@ -43,7 +42,9 @@ const uploadMaxBytes = (() => { if (trimmed === '0' || trimmed.toLowerCase() === 'infinity') return Infinity const n = Number(trimmed) if (!Number.isFinite(n) || n <= 0) { - console.warn(`[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes`) + console.warn( + `[upload] invalid UPLOAD_MAX_BYTES=${JSON.stringify(raw)}; falling back to default ${DEFAULT_UPLOAD_MAX_BYTES} bytes` + ) return DEFAULT_UPLOAD_MAX_BYTES } return Math.max(1, Math.floor(n)) @@ -58,6 +59,22 @@ function pickStr(v: unknown): string { return typeof v === 'string' ? v : '' } +function pickIntId(v: unknown): number | null { + // JSON body 로 들어오면 number, form 으로 들어오면 string. 둘 다 지원. + let n: number + if (typeof v === 'number') { + n = v + } else { + const s = pickStr(v).trim() + if (!s) return null + n = Number(s) + } + if (!Number.isFinite(n) || n < 1 || Math.floor(n) !== n) return null + return n +} + +// ─── 인증 ───────────────────────────────────────────────────────────── + opRouter.get('/op', (req, res) => { if (req.session?.userId) { res.redirect('/op/dashboard') @@ -88,119 +105,114 @@ opRouter.post('/op/logout', (req, res) => { }) }) -opRouter.get('/op/dashboard', requireAuth, async (req, res, next) => { +// ─── 폴더 뷰 ────────────────────────────────────────────────────────── + +opRouter.get('/op/dashboard', requireAuth, (req, res, next) => { try { - const folders = await listFolders() + const folders = listTopFolders() res.render('op/dashboard', { userId: req.session.userId, folders }) } catch (err) { next(err) } }) -opRouter.post('/op/folders', requireAuth, async (req, res, next) => { +function folderViewHandler(req: Request, res: Response, next: NextFunction): void { 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) { + const segments = collectFolderSegments(req.params) + const folder = getFolderByPath(segments) + if (!folder) { 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', { + const subFolders = folder.parentId === null ? listChildFolders(folder.id) : [] + const videos = listVideosInFolder(folder.id) + res.render('op/folder', { userId: req.session.userId, - folder: safe, - video + folder, + breadcrumb: folderPathNames(folder.id), + subFolders, + videos, + isSubFolder: folder.parentId !== null }) } catch (err) { next(err) } +} +opRouter.get('/op/folder/:topName', requireAuth, folderViewHandler) +opRouter.get('/op/folder/:topName/:subName', requireAuth, folderViewHandler) + +// ─── 폴더 mutation (id 기반) ─────────────────────────────────────────── + +opRouter.post('/op/folders', requireAuth, async (req, res) => { + try { + const name = pickStr(req.body.name) + const parentIdRaw = req.body.parentId + const parentId = + parentIdRaw === undefined || parentIdRaw === null || parentIdRaw === '' + ? null + : pickIntId(parentIdRaw) + if (parentIdRaw !== undefined && parentIdRaw !== null && parentIdRaw !== '' && parentId === null) { + throw new Error('상위 폴더 ID 가 올바르지 않습니다.') + } + const folder = await createFolder({ name, parentId }) + res.json({ ok: true, folder }) + } catch (err) { + res.status(400).json({ ok: false, message: (err as Error).message }) + } }) -opRouter.post('/op/folder/:name/video/rename', requireAuth, async (req, res) => { +opRouter.post('/op/folders/:id/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) + const id = pickIntId(req.params.id) + if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.') + const newName = pickStr(req.body.newName) + const folder = await renameFolder(id, newName) + res.json({ ok: true, folder }) + } catch (err) { + res.status(400).json({ ok: false, message: (err as Error).message }) + } +}) + +opRouter.post('/op/folders/:id/delete', requireAuth, async (req, res) => { + try { + const id = pickIntId(req.params.id) + if (id === null) throw new Error('폴더 ID 가 올바르지 않습니다.') + await deleteFolder(id) 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 }) - } -}) +// ─── 영상 에디터 페이지 ──────────────────────────────────────────────── + +opRouter.get( + ['/op/folder/:topName/video/editor', '/op/folder/:topName/:subName/video/editor'], + requireAuth, + (req, res, next) => { + try { + const segments = collectFolderSegments(req.params) + const folder = getFolderByPath(segments) + if (!folder) { + res.status(404).send('폴더를 찾을 수 없습니다.') + return + } + const videoId = typeof req.query.id === 'string' ? req.query.id : null + const video = videoId ? getVideo(videoId) : null + res.render('op/editor', { + userId: req.session.userId, + folder, + breadcrumb: folderPathNames(folder.id), + video + }) + } catch (err) { + next(err) + } + } +) + +// ─── 영상 업로드 / 유튜브 ─────────────────────────────────────────────── -// multer 가 던지는 LIMIT_FILE_SIZE 같은 에러를 라우트 핸들러가 잡지 못해 -// 글로벌 에러 핸들러로 새서 stack trace 가 그대로 노출되던 문제를 막는다. function uploadSingle(fieldName: string) { const mw = upload.single(fieldName) return (req: Request, res: Response, next: NextFunction) => { @@ -219,80 +231,76 @@ function uploadSingle(fieldName: string) { } } -// 업로드: 단일 파일. multipart/form-data, fields: title, file opRouter.post( - '/op/folder/:name/video/upload', + ['/op/folder/:topName/video/upload', '/op/folder/:topName/:subName/video/upload'], requireAuth, uploadSingle('file'), async (req, res) => { try { - const safe = sanitizeFolderName(req.params.name) - if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') + const folder = getFolderByPath(collectFolderSegments(req.params)) + if (!folder) 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, + const video = await createVideo({ + folderId: folder.id, 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 }) + sourceType: 'upload' + }) + await moveUploadIntoVideoDir(video.id, file.path, destName) + res.json({ ok: true, videoId: video.id }) } 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 +opRouter.post( + ['/op/folder/:topName/video/youtube/probe', '/op/folder/:topName/:subName/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 }) } - 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 +opRouter.post( + ['/op/folder/:topName/video/youtube/start', '/op/folder/:topName/:subName/video/youtube/start'], + requireAuth, + async (req, res) => { + try { + const folder = getFolderByPath(collectFolderSegments(req.params)) + if (!folder) 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({ folderId: folder.id, 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 }) } - res.status(400).json({ ok: false, message: (err as Error).message }) } -}) +) opRouter.get('/op/job/:id', requireAuth, (req, res) => { - // 폴링 응답이 304 로 캐싱되면 브라우저가 body 없는 응답을 돌려줘서 - // 클라이언트의 r.json() 이 reject → 폴링이 중단된다. 항상 신선한 응답. + // 폴링 응답이 304 로 캐싱되면 클라이언트의 r.json() 이 reject → 폴링 중단된다. res.set('Cache-Control', 'no-store, no-cache, must-revalidate') res.set('Pragma', 'no-cache') const job = getJob(req.params.id) @@ -303,12 +311,58 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => { res.json({ ok: true, job }) }) -// 편집 저장: trim 정보를 받아 ffmpeg 로 edited. 생성. 원본은 그대로 보존. -opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => { +// ─── 영상 mutation (id 기반 — 전역 유일 ID) ───────────────────────────── + +opRouter.post('/op/videos/:id/rename', requireAuth, (req, res) => { try { - const safe = sanitizeFolderName(req.params.name) - if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') - const id = pickStr(req.body.id) + const id = req.params.id + const title = pickStr(req.body.title).trim() + if (!title) throw new Error('제목을 입력해 주세요.') + if (!getVideo(id)) throw new Error('영상을 찾을 수 없습니다.') + updateVideo(id, { title }) + res.json({ ok: true }) + } catch (err) { + res.status(400).json({ ok: false, message: (err as Error).message }) + } +}) + +opRouter.post('/op/videos/:id/changeId', requireAuth, async (req, res) => { + try { + const oldId = req.params.id + const newId = pickStr(req.body.newId).trim() + if (!newId) throw new Error('새 ID 를 입력해 주세요.') + const video = await changeVideoId(oldId, newId) + res.json({ ok: true, video }) + } catch (err) { + res.status(400).json({ ok: false, message: (err as Error).message }) + } +}) + +/** 새 ID 가 사용 가능한지 미리 확인 (인풋 옆 실시간 표시용). */ +opRouter.get('/op/videos/idAvailable', requireAuth, (req, res) => { + const id = typeof req.query.id === 'string' ? req.query.id.trim() : '' + if (!id) { + res.json({ ok: true, available: false, reason: '비어 있음' }) + return + } + const available = !videoIdExists(id) + res.json({ ok: true, available }) +}) + +opRouter.post('/op/videos/:id/delete', requireAuth, async (req, res) => { + try { + await deleteVideo(req.params.id) + res.json({ ok: true }) + } catch (err) { + res.status(400).json({ ok: false, message: (err as Error).message }) + } +}) + +opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => { + try { + const id = req.params.id + const video = getVideo(id) + if (!video) throw new Error('영상을 찾을 수 없습니다.') const title = pickStr(req.body.title).trim() const startSec = Number(req.body.startSec ?? 0) || 0 const endRaw = req.body.endSec @@ -316,14 +370,14 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => { 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) + const trim = { + startSec, + endSec: endSec == null || Number.isNaN(endSec) ? null : endSec + } + updateVideo(id, { trim, ...(title ? { title } : {}) }) // ffmpeg 가 없으면 trim 정보만 저장하고 안내. try { - const outName = await applyTrimToVideo(safe, id, meta.trim) + const outName = await applyTrimToVideo(id, trim) res.json({ ok: true, editedFile: outName, note: '편집본 저장 완료' }) } catch (err) { if (err instanceof FfmpegUnavailableError) { @@ -340,3 +394,26 @@ opRouter.post('/op/folder/:name/video/save', requireAuth, async (req, res) => { res.status(400).json({ ok: false, message: (err as Error).message }) } }) + +// ─── 헬퍼 ───────────────────────────────────────────────────────────── + +function collectFolderSegments(params: Record): string[] { + const out: string[] = [] + if (params.topName) out.push(params.topName) + if (params.subName) out.push(params.subName) + return out +} + +/** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */ +export function adminFolderUrl(folderId: number): string { + const names = folderPathNames(folderId) + return '/op/folder/' + names.map((s) => encodeURIComponent(s)).join('/') +} + +/** 비디오 ID → 공개 공유 URL (/video/topName/[subName/]videoId). */ +export function videoShareUrl(videoId: string): string | null { + const v = getVideo(videoId) + if (!v) return null + const names = folderPathNames(v.folderId) + return '/video/' + [...names, videoId].map((s) => encodeURIComponent(s)).join('/') +} diff --git a/src/routes/public.ts b/src/routes/public.ts index b3b913c..8233fa8 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,84 +1,108 @@ import { Router, type RequestHandler } from 'express' -import path from 'node:path' -import { promises as fs } from 'node:fs' import { - findVideoAnywhere, - folderPath, - listFolders, - listVideos, - loadVideoMeta, - sanitizeFolderName, - videoDir, - videoFileFsPath -} from '../store.js' + getFolderByPath, + getVideo, + listChildFolders, + listTopFolders, + listVideosInFolder, + folderPathNames, + videoFileFsPath, + type Folder, + type Video +} from '../storeDb.js' export const publicRouter = Router() -publicRouter.get('/', async (_req, res, next) => { +publicRouter.get('/', (_req, res, next) => { try { - const folders = await listFolders() + const folders = listTopFolders() res.render('index', { folders }) } catch (err) { next(err) } }) -publicRouter.get('/folder/:name', async (req, res, next) => { +/** + * 폴더 보기 (공개). + * - /folder/:topName — 최상위 폴더 (자식 폴더 + 영상 혼합 가능) + * - /folder/:topName/:subName — 서브폴더 (영상만) + */ +const folderViewHandler: RequestHandler = (req, res, next) => { try { - const safe = sanitizeFolderName(req.params.name) - if (!safe) { + const segments = collectFolderSegments(req.params) + const folder = getFolderByPath(segments) + if (!folder) { res.status(404).send('폴더를 찾을 수 없습니다.') return } - // 존재 확인 - try { - await fs.access(folderPath(safe)) - } catch { - res.status(404).send('폴더를 찾을 수 없습니다.') - return - } - const videos = await listVideos(safe) - res.render('folder', { folder: safe, videos, isAdmin: false }) + const subFolders = folder.parentId === null ? listChildFolders(folder.id) : [] + const videos = listVideosInFolder(folder.id) + res.render('folder', { + folder, + breadcrumb: folderPathNames(folder.id), + subFolders, + videos, + isAdmin: false, + isSubFolder: folder.parentId !== null + }) } catch (err) { next(err) } -}) +} +publicRouter.get('/folder/:topName', folderViewHandler) +publicRouter.get('/folder/:topName/:subName', folderViewHandler) -publicRouter.get('/player/:videoId', async (req, res, next) => { +/** + * 영상 보기 (공개). + * - /video/:topName/:videoId + * - /video/:topName/:subName/:videoId + * + * 경로의 마지막 세그먼트는 영상 ID. 앞쪽은 그 영상이 속한 폴더 경로와 일치해야 한다. + * (DB lookup 한 번이므로 path 가 ID 의 정합성 검증 역할도 함) + */ +const videoViewHandler: RequestHandler = (req, res, next) => { try { - const found = await findVideoAnywhere(req.params.videoId) - if (!found) { + const { folderSegments, videoId } = collectVideoSegments(req.params) + const video = getVideo(videoId) + if (!video) { res.status(404).send('영상을 찾을 수 없습니다.') return } - res.render('player', { folder: found.folder, video: found.meta }) + const folderPath = folderPathNames(video.folderId) + if (!segmentsEqual(folderPath, folderSegments)) { + res.status(404).send('영상 경로가 일치하지 않습니다.') + return + } + res.render('player', { video, breadcrumb: folderPath }) } catch (err) { next(err) } -}) +} +publicRouter.get('/video/:topName/:videoId', videoViewHandler) +publicRouter.get('/video/:topName/:subName/:videoId', videoViewHandler) /** - * 영상 파일 스트리밍. - * - 짧은 외부 공유용 경로: /file/video/:videoId - * - 기존 경로: /api/video/:videoId/file (호환용으로 유지) - * - ?edited=0 이면 원본을 강제하고, 기본은 편집본 있으면 편집본. + * 영상 파일 스트리밍 (짧은 ID-only URL — 외부 공유 호환용). + * - GET /file/video/:videoId + * - GET /api/video/:videoId/file (legacy alias) + * ?edited=0 이면 원본을 강제, 기본은 편집본 있으면 편집본. */ -const streamVideoHandler: RequestHandler = async (req, res, next) => { +const streamVideoHandler: RequestHandler = (req, res, next) => { try { - const found = await findVideoAnywhere(req.params.videoId) - if (!found) { + const video = getVideo(req.params.videoId) + if (!video) { res.status(404).end() return } const editedParam = typeof req.query.edited === 'string' ? req.query.edited : '' const wantOriginal = editedParam === '0' || editedParam === 'false' const fileName = - !wantOriginal && found.meta.editedFile ? found.meta.editedFile : found.meta.originalFile + !wantOriginal && video.editedFile ? video.editedFile : video.originalFile if (!fileName || fileName.includes('%(ext)s')) { res.status(404).end() return } - const fsPath = videoFileFsPath(found.folder, found.meta.id, fileName) + const fsPath = videoFileFsPath(video.id, fileName) res.sendFile(fsPath) } catch (err) { next(err) @@ -87,16 +111,49 @@ const streamVideoHandler: RequestHandler = async (req, res, next) => { publicRouter.get('/file/video/:videoId', streamVideoHandler) publicRouter.get('/api/video/:videoId/file', streamVideoHandler) -/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용) */ -publicRouter.get('/api/video/:videoId', async (req, res, next) => { +/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */ +publicRouter.get('/api/video/:videoId', (req, res, next) => { try { - const found = await findVideoAnywhere(req.params.videoId) - if (!found) { + const video = getVideo(req.params.videoId) + if (!video) { res.status(404).json({ ok: false, message: '영상을 찾을 수 없습니다.' }) return } - res.json({ ok: true, folder: found.folder, video: found.meta }) + res.json({ + ok: true, + video, + breadcrumb: folderPathNames(video.folderId) + }) } catch (err) { next(err) } }) + +// ─── 헬퍼 ───────────────────────────────────────────────────────────── + +function collectFolderSegments(params: Record): string[] { + const out: string[] = [] + if (params.topName) out.push(params.topName) + if (params.subName) out.push(params.subName) + return out +} + +function collectVideoSegments(params: Record): { + folderSegments: string[] + videoId: string +} { + const folderSegments: string[] = [] + if (params.topName) folderSegments.push(params.topName) + if (params.subName) folderSegments.push(params.subName) + return { folderSegments, videoId: params.videoId ?? '' } +} + +function segmentsEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +export type { Folder, Video } diff --git a/src/store.ts b/src/store.ts index d55b4c1..5a364a9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,31 +1,12 @@ -import { promises as fs, createReadStream, createWriteStream } from 'node:fs' -import path from 'node:path' -import { randomUUID } from 'node:crypto' -import { foldersDir, accountJsonPath } from './paths.js' +import { promises as fs } from 'node:fs' +import { accountJsonPath } from './paths.js' export interface Account { id: string password: string } -export interface VideoTrim { - startSec: number - endSec: number | null // null = until end -} - -export interface VideoMeta { - id: string - title: string - originalFile: string // relative to // - editedFile: string | null - durationSec: number | null - sourceType: 'upload' | 'youtube' - sourceUrl: string | null - trim: VideoTrim | null - createdAt: string - updatedAt: string -} - +/** account.json 에서 로그인 계정 목록을 읽는다. (나머지 파일 기반 저장소는 storeDb.ts 로 이전됨.) */ export async function readAccounts(): Promise { try { const raw = await fs.readFile(accountJsonPath, 'utf8') @@ -40,181 +21,3 @@ export async function readAccounts(): Promise { throw err } } - -// 폴더/영상 이름에 사용 가능한 문자만 허용. 경로 탈출 방지. -const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u - -export function sanitizeFolderName(raw: string): string { - const trimmed = (raw || '').trim() - if (trimmed.length === 0 || trimmed.length > 80) return '' - if (!SAFE_NAME.test(trimmed)) return '' - if (trimmed === '.' || trimmed === '..') return '' - return trimmed -} - -export function isSafeVideoId(id: string): boolean { - return /^[a-zA-Z0-9_-]{8,64}$/.test(id) -} - -export async function ensureFoldersDir(): Promise { - await fs.mkdir(foldersDir, { recursive: true }) -} - -export async function listFolders(): Promise { - await ensureFoldersDir() - const entries = await fs.readdir(foldersDir, { withFileTypes: true }) - return entries - .filter((e) => e.isDirectory()) - .map((e) => e.name) - .filter((name) => sanitizeFolderName(name).length > 0) - .sort((a, b) => a.localeCompare(b, 'ko')) -} - -export async function createFolder(name: string): Promise { - const safe = sanitizeFolderName(name) - if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') - const target = path.join(foldersDir, safe) - await fs.mkdir(target, { recursive: false }).catch((err) => { - if (err.code === 'EEXIST') throw new Error('이미 존재하는 폴더입니다.') - throw err - }) - return safe -} - -export async function renameFolder(oldName: string, newName: string): Promise { - const oldSafe = sanitizeFolderName(oldName) - const newSafe = sanitizeFolderName(newName) - if (!oldSafe || !newSafe) throw new Error('폴더 이름이 올바르지 않습니다.') - if (oldSafe === newSafe) return oldSafe - const from = path.join(foldersDir, oldSafe) - const to = path.join(foldersDir, newSafe) - try { - await fs.access(to) - throw new Error('이미 존재하는 폴더입니다.') - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err - } - await fs.rename(from, to) - return newSafe -} - -export async function deleteFolder(name: string): Promise { - const safe = sanitizeFolderName(name) - if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') - const target = path.join(foldersDir, safe) - await fs.rm(target, { recursive: true, force: true }) -} - -export function folderPath(name: string): string { - const safe = sanitizeFolderName(name) - if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') - return path.join(foldersDir, safe) -} - -export function videoDir(folder: string, videoId: string): string { - if (!isSafeVideoId(videoId)) throw new Error('비디오 ID가 올바르지 않습니다.') - return path.join(folderPath(folder), videoId) -} - -export function videoMetaPath(folder: string, videoId: string): string { - return path.join(videoDir(folder, videoId), 'meta.json') -} - -export async function listVideos(folder: string): Promise { - const dir = folderPath(folder) - try { - const entries = await fs.readdir(dir, { withFileTypes: true }) - const metas: VideoMeta[] = [] - for (const entry of entries) { - if (!entry.isDirectory()) continue - if (!isSafeVideoId(entry.name)) continue - const meta = await loadVideoMeta(folder, entry.name).catch(() => null) - if (meta) metas.push(meta) - } - metas.sort((a, b) => (b.createdAt < a.createdAt ? -1 : 1)) - return metas - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw err - } -} - -export async function loadVideoMeta(folder: string, videoId: string): Promise { - try { - const raw = await fs.readFile(videoMetaPath(folder, videoId), 'utf8') - return JSON.parse(raw) as VideoMeta - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null - throw err - } -} - -export async function findVideoAnywhere( - videoId: string -): Promise<{ folder: string; meta: VideoMeta } | null> { - if (!isSafeVideoId(videoId)) return null - const folders = await listFolders() - for (const folder of folders) { - const meta = await loadVideoMeta(folder, videoId).catch(() => null) - if (meta) return { folder, meta } - } - return null -} - -export async function saveVideoMeta(folder: string, meta: VideoMeta): Promise { - const dir = videoDir(folder, meta.id) - await fs.mkdir(dir, { recursive: true }) - meta.updatedAt = new Date().toISOString() - await fs.writeFile(videoMetaPath(folder, meta.id), JSON.stringify(meta, null, 2)) -} - -export async function deleteVideo(folder: string, videoId: string): Promise { - const dir = videoDir(folder, videoId) - await fs.rm(dir, { recursive: true, force: true }) -} - -export function newVideoId(): string { - // URL-safe 22-char id (uuid 압축). - return randomUUID().replace(/-/g, '').slice(0, 24) -} - -export function videoFileFsPath(folder: string, videoId: string, rel: string): string { - // rel 은 meta 에 저장된 파일명. 경로 탈출 방지. - if (rel.includes('/') || rel.includes('\\') || rel.includes('..')) { - throw new Error('잘못된 파일 경로입니다.') - } - return path.join(videoDir(folder, videoId), rel) -} - -/** 업로드 임시 파일을 비디오 디렉토리로 이동. */ -export async function moveUploadIntoVideo( - folder: string, - videoId: string, - tmpFile: string, - destName: string -): Promise { - if (destName.includes('/') || destName.includes('\\') || destName.includes('..')) { - throw new Error('잘못된 파일명입니다.') - } - const dir = videoDir(folder, videoId) - await fs.mkdir(dir, { recursive: true }) - const target = path.join(dir, destName) - try { - await fs.rename(tmpFile, target) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'EXDEV') { - // 다른 디바이스에 있으면 copy + unlink - await new Promise((resolve, reject) => { - const r = createReadStream(tmpFile) - const w = createWriteStream(target) - r.on('error', reject) - w.on('error', reject) - w.on('close', () => resolve()) - r.pipe(w) - }) - await fs.unlink(tmpFile).catch(() => undefined) - } else { - throw err - } - } -} diff --git a/src/storeDb.ts b/src/storeDb.ts new file mode 100644 index 0000000..311e8fe --- /dev/null +++ b/src/storeDb.ts @@ -0,0 +1,524 @@ +/** + * DB(SQLite) 기반 폴더/영상 CRUD + 디스크 동기화 레이어. + * + * 디스크 레이아웃 (변경 없음): + * data/folders/{topName}/[{subName}/]{videoId}/... + * + * DB 가 source of truth. 변경 흐름: + * 1) 검증 (이름/ID/깊이/존재여부) + * 2) DB UPDATE/INSERT/DELETE + * 3) 디스크 동기화 (mkdir / rename / rm) + * 4) 실패 시 DB 롤백 (사용 가능한 경우) + * + * DB → 디스크 순서로 가는 이유: DB 가 UNIQUE 등 제약을 검사해 주므로 충돌을 일찍 잡고 + * 디스크 작업으로 안 넘어간다. 디스크 작업이 실패하면 DB 변경을 되돌린다. + */ +import { promises as fsp } from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { getDb } from './db.js' +import { foldersDir } from './paths.js' + +// ─── 검증 ────────────────────────────────────────────────────────────── + +const SAFE_NAME = /^[\p{L}\p{N}_\- ]+$/u +const SAFE_VIDEO_ID = /^[a-zA-Z0-9_-]{1,64}$/ + +export function sanitizeFolderName(raw: string): string { + const trimmed = (raw || '').trim() + if (!trimmed || trimmed.length > 80) return '' + if (trimmed === '.' || trimmed === '..') return '' + if (!SAFE_NAME.test(trimmed)) return '' + return trimmed +} + +export function isSafeVideoId(id: string): boolean { + return typeof id === 'string' && SAFE_VIDEO_ID.test(id) +} + +export function newRandomVideoId(): string { + // URL-safe 24-char id (uuid 압축). + return randomUUID().replace(/-/g, '').slice(0, 24) +} + +// ─── 타입 ────────────────────────────────────────────────────────────── + +export interface Folder { + id: number + parentId: number | null + name: string + position: number + createdAt: string + updatedAt: string +} + +export interface VideoTrim { + startSec: number + endSec: number | null +} + +export interface Video { + id: string + folderId: number + title: string + originalFile: string + editedFile: string | null + durationSec: number | null + sourceType: 'upload' | 'youtube' + sourceUrl: string | null + trim: VideoTrim | null + position: number + createdAt: string + updatedAt: string +} + +interface FolderRow { + id: number + parent_id: number | null + name: string + position: number + created_at: string + updated_at: string +} + +interface VideoRow { + id: string + folder_id: number + title: string + original_file: string + edited_file: string | null + duration_sec: number | null + source_type: 'upload' | 'youtube' + source_url: string | null + trim_start_sec: number | null + trim_end_sec: number | null + position: number + created_at: string + updated_at: string +} + +function rowToFolder(r: FolderRow): Folder { + return { + id: r.id, + parentId: r.parent_id, + name: r.name, + position: r.position, + createdAt: r.created_at, + updatedAt: r.updated_at + } +} + +function rowToVideo(r: VideoRow): Video { + const hasTrim = r.trim_start_sec !== null || r.trim_end_sec !== null + return { + id: r.id, + folderId: r.folder_id, + title: r.title, + originalFile: r.original_file, + editedFile: r.edited_file, + durationSec: r.duration_sec, + sourceType: r.source_type, + sourceUrl: r.source_url, + trim: hasTrim + ? { startSec: r.trim_start_sec ?? 0, endSec: r.trim_end_sec } + : null, + position: r.position, + createdAt: r.created_at, + updatedAt: r.updated_at + } +} + +// ─── 폴더 조회 ───────────────────────────────────────────────────────── + +export function listTopFolders(): Folder[] { + const rows = getDb() + .prepare( + `SELECT * FROM folders WHERE parent_id IS NULL ORDER BY position, name` + ) + .all() as FolderRow[] + return rows.map(rowToFolder) +} + +export function listChildFolders(parentId: number): Folder[] { + const rows = getDb() + .prepare(`SELECT * FROM folders WHERE parent_id = ? ORDER BY position, name`) + .all(parentId) as FolderRow[] + return rows.map(rowToFolder) +} + +export function getFolder(id: number): Folder | null { + const r = getDb() + .prepare(`SELECT * FROM folders WHERE id = ?`) + .get(id) as FolderRow | undefined + return r ? rowToFolder(r) : null +} + +export function getTopFolderByName(name: string): Folder | null { + const safe = sanitizeFolderName(name) + if (!safe) return null + const r = getDb() + .prepare(`SELECT * FROM folders WHERE parent_id IS NULL AND name = ?`) + .get(safe) as FolderRow | undefined + return r ? rowToFolder(r) : null +} + +export function getChildFolderByName(parentId: number, name: string): Folder | null { + const safe = sanitizeFolderName(name) + if (!safe) return null + const r = getDb() + .prepare(`SELECT * FROM folders WHERE parent_id = ? AND name = ?`) + .get(parentId, safe) as FolderRow | undefined + return r ? rowToFolder(r) : null +} + +/** URL 경로 [topName] 또는 [topName, subName] → 폴더 1건. 못 찾으면 null. */ +export function getFolderByPath(names: string[]): Folder | null { + if (names.length === 0 || names.length > 2) return null + const top = getTopFolderByName(names[0]) + if (!top) return null + if (names.length === 1) return top + return getChildFolderByName(top.id, names[1]) +} + +/** 폴더 → 디스크 경로 (parent 까지 거슬러 올라가 이름 join). */ +export function folderDiskPath(folderId: number): string { + const names = folderPathNames(folderId) + return path.join(foldersDir, ...names) +} + +/** 폴더 → URL 경로 세그먼트 배열 (인코딩 전). */ +export function folderPathNames(folderId: number): string[] { + const names: string[] = [] + let cur: Folder | null = getFolder(folderId) + while (cur) { + names.unshift(cur.name) + cur = cur.parentId !== null ? getFolder(cur.parentId) : null + } + return names +} + +// ─── 폴더 변경 ───────────────────────────────────────────────────────── + +export async function createFolder(input: { + name: string + parentId: number | null +}): Promise { + const safe = sanitizeFolderName(input.name) + if (!safe) throw new Error('폴더 이름이 올바르지 않습니다.') + // 깊이 검사: 부모가 이미 서브폴더면 손자 폴더는 만들 수 없다 (최대 2단계). + if (input.parentId !== null) { + const parent = getFolder(input.parentId) + if (!parent) throw new Error('상위 폴더를 찾을 수 없습니다.') + if (parent.parentId !== null) { + throw new Error('서브폴더 안에는 폴더를 만들 수 없습니다.') + } + } + const now = new Date().toISOString() + const db = getDb() + // position = 현재 형제 개수 (끝에 추가) + const siblings = (db + .prepare( + input.parentId === null + ? `SELECT COUNT(*) as c FROM folders WHERE parent_id IS NULL` + : `SELECT COUNT(*) as c FROM folders WHERE parent_id = ?` + ) + .get(...(input.parentId === null ? [] : [input.parentId])) as { c: number }).c + + let info + try { + info = db + .prepare( + `INSERT INTO folders (parent_id, name, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)` + ) + .run(input.parentId, safe, siblings, now, now) + } catch (err) { + if ((err as Error).message.includes('UNIQUE')) { + throw new Error('이미 존재하는 폴더입니다.') + } + throw err + } + const folderId = Number(info.lastInsertRowid) + // 디스크 디렉토리 생성 (이미 있을 수 있음 — 마이그레이션 케이스). + const diskPath = folderDiskPath(folderId) + try { + await fsp.mkdir(diskPath, { recursive: true }) + } catch (err) { + // 디스크 실패 시 DB 롤백. + db.prepare(`DELETE FROM folders WHERE id = ?`).run(folderId) + throw err + } + return getFolder(folderId)! +} + +export async function renameFolder(folderId: number, newName: string): Promise { + const folder = getFolder(folderId) + if (!folder) throw new Error('폴더를 찾을 수 없습니다.') + const safe = sanitizeFolderName(newName) + if (!safe) throw new Error('새 폴더 이름이 올바르지 않습니다.') + if (safe === folder.name) return folder + + const oldDisk = folderDiskPath(folderId) + const db = getDb() + try { + db.prepare(`UPDATE folders SET name = ?, updated_at = ? WHERE id = ?`).run( + safe, + new Date().toISOString(), + folderId + ) + } catch (err) { + if ((err as Error).message.includes('UNIQUE')) { + throw new Error('이미 존재하는 폴더 이름입니다.') + } + throw err + } + const newDisk = folderDiskPath(folderId) + try { + await fsp.rename(oldDisk, newDisk) + } catch (err) { + // ENOENT (디스크 디렉토리가 없는 경우) 는 무시하고 비어있는 새 디렉토리 생성. + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + await fsp.mkdir(newDisk, { recursive: true }) + } else { + // 디스크 rename 실패 → DB 롤백 + db.prepare(`UPDATE folders SET name = ? WHERE id = ?`).run(folder.name, folderId) + throw err + } + } + return getFolder(folderId)! +} + +export async function deleteFolder(folderId: number): Promise { + const folder = getFolder(folderId) + if (!folder) return // idempotent + const diskPath = folderDiskPath(folderId) + // DB CASCADE 가 자식 폴더/영상까지 모두 삭제. + getDb().prepare(`DELETE FROM folders WHERE id = ?`).run(folderId) + await fsp.rm(diskPath, { recursive: true, force: true }) +} + +// ─── 영상 조회 ───────────────────────────────────────────────────────── + +export function getVideo(id: string): Video | null { + if (!isSafeVideoId(id)) return null + const r = getDb() + .prepare(`SELECT * FROM videos WHERE id = ?`) + .get(id) as VideoRow | undefined + return r ? rowToVideo(r) : null +} + +export function listVideosInFolder(folderId: number): Video[] { + const rows = getDb() + .prepare( + `SELECT * FROM videos WHERE folder_id = ? ORDER BY position, created_at DESC` + ) + .all(folderId) as VideoRow[] + return rows.map(rowToVideo) +} + +export function videoIdExists(id: string): boolean { + if (!isSafeVideoId(id)) return false + const r = getDb() + .prepare(`SELECT 1 FROM videos WHERE id = ?`) + .get(id) + return !!r +} + +/** 영상 → 디스크 디렉토리 (폴더 경로 + videoId). */ +export function videoDiskDir(videoId: string): string { + const v = getVideo(videoId) + if (!v) throw new Error('영상을 찾을 수 없습니다.') + return path.join(folderDiskPath(v.folderId), v.id) +} + +/** 영상 → 공유 URL 의 경로 부분 (인코딩 전 세그먼트). */ +export function videoPathNames(videoId: string): string[] | null { + const v = getVideo(videoId) + if (!v) return null + return [...folderPathNames(v.folderId), v.id] +} + +/** 영상 디렉토리 내 파일의 절대 경로. 경로 탈출 방지. */ +export function videoFileFsPath(videoId: string, rel: string): string { + if (!rel || rel.includes('/') || rel.includes('\\') || rel.includes('..')) { + throw new Error('잘못된 파일 경로입니다.') + } + return path.join(videoDiskDir(videoId), rel) +} + +// ─── 영상 변경 ───────────────────────────────────────────────────────── + +export interface CreateVideoInput { + id?: string // 미지정 시 랜덤 + folderId: number + title: string + originalFile: string // 디렉토리 안 상대 경로 (또는 'original.%(ext)s' 같은 yt-dlp 임시 패턴) + sourceType: 'upload' | 'youtube' + sourceUrl?: string | null +} + +export async function createVideo(input: CreateVideoInput): Promise관리자 + <% + // 현재 폴더까지의 경로 prefix (영상/하위폴더 URL 조립용) + var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/') + var parentHref = isSubFolder ? '/folder/' + encodeURIComponent(breadcrumb[0]) : '/' + var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록' + %> +
- ← 폴더 목록 -

📁 <%= folder %>

+ <%= parentLabel %> +

📁 <%= breadcrumb.join(' / ') %>

+ <% if (subFolders && subFolders.length > 0) { %> +
+ <% subFolders.forEach(function (sf) { %> + + 📁 + <%= sf.name %> + + <% }) %> +
+ <% } %> +
- <% if (videos.length === 0) { %> + <% if (videos.length === 0 && (!subFolders || subFolders.length === 0)) { %>

이 폴더에 영상이 없습니다.

<% } %> <% videos.forEach(function (v) { %> - + <% }) %>
- - - - diff --git a/views/index.ejs b/views/index.ejs index 5d334b4..9262d83 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -25,10 +25,10 @@ <% if (folders.length === 0) { %>

아직 폴더가 없습니다. 관리자 페이지에서 폴더를 추가해 주세요.

<% } %> - <% folders.forEach(function (name) { %> - + <% folders.forEach(function (f) { %> + 📁 - <%= name %> + <%= f.name %> <% }) %> diff --git a/views/op/dashboard.ejs b/views/op/dashboard.ejs index 6ada2fb..aeb1be0 100644 --- a/views/op/dashboard.ejs +++ b/views/op/dashboard.ejs @@ -21,11 +21,11 @@ <% if (folders.length === 0) { %>

아직 폴더가 없습니다. 우측 상단에서 폴더를 추가해 주세요.

<% } %> - <% folders.forEach(function (name) { %> -
- + <% folders.forEach(function (f) { %> + <% }) %> diff --git a/views/op/editor.ejs b/views/op/editor.ejs index c234f97..73e9e06 100644 --- a/views/op/editor.ejs +++ b/views/op/editor.ejs @@ -3,13 +3,18 @@ - 영상 편집 · <%= folder %> + 영상 편집 · <%= breadcrumb.join(' / ') %> + <% + var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/') + var folderHref = '/op/folder/' + folderPathEnc + var folderLabel = breadcrumb.join(' / ') + %>
@@ -35,7 +40,7 @@
hidden<% } %>> + <% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>>
@@ -66,7 +71,8 @@ diff --git a/views/op/folder.ejs b/views/op/folder.ejs index b7b623e..2cc328c 100644 --- a/views/op/folder.ejs +++ b/views/op/folder.ejs @@ -3,32 +3,65 @@ - 관리자 · <%= folder %> + 관리자 · <%= breadcrumb.join(' / ') %> <%- include('../partials/navbar', { userId }) %> + <% + var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/') + var parentHref = isSubFolder ? '/op/folder/' + encodeURIComponent(breadcrumb[0]) : '/op/dashboard' + var parentLabel = isSubFolder ? '← ' + breadcrumb[0] : '← 폴더 목록' + var editorHref = '/op/folder/' + folderPathEnc + '/video/editor' + var playlistHref = '/op/folder/' + folderPathEnc + '/playlist/new' + %> +
- ← 폴더 목록 -

📁 <%= folder %>

+ <%= parentLabel %> +

📁 <%= breadcrumb.join(' / ') %>

- 영상 추가 + <% if (!isSubFolder) { %> + + <% } %> + 영상 추가 + <% if (isSubFolder) { %> + 플레이리스트 추가 + <% } %>
+ <% if (!isSubFolder && subFolders && subFolders.length > 0) { %> +
+ <% subFolders.forEach(function (sf) { %> + + <% }) %> +
+ <% } %> +
- <% if (videos.length === 0) { %> + <% if (videos.length === 0 && (isSubFolder || !subFolders || subFolders.length === 0)) { %>

이 폴더에 영상이 없습니다. 우측 상단에서 영상을 추가하세요.

<% } %> - <% videos.forEach(function (v) { %> -
+ <% videos.forEach(function (v) { + var shareUrl = '/video/' + folderPathEnc + '/' + encodeURIComponent(v.id) + %> +
<%= v.title %>
- <% if (v.sourceType === 'youtube' && !v.originalFile.includes('original.') === false) { %> + <% if (v.sourceType === 'youtube') { %>
YouTube
<% } %>
@@ -39,10 +72,16 @@ + + + + diff --git a/views/player.ejs b/views/player.ejs index 6e8be7a..7ca14a7 100644 --- a/views/player.ejs +++ b/views/player.ejs @@ -7,22 +7,27 @@ + <% + var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/') + var folderHref = '/folder/' + folderPathEnc + var folderLabel = breadcrumb.join(' / ') + %>
비디오 사이트 - 폴더로 + 폴더로
- ← <%= folder %> + ← <%= folderLabel %>

<%= video.title %>

- +