feat(folders): nested folder tree (max depth 2) + id-based mutations
- 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 <noreply@anthropic.com>
This commit is contained in:
203
src/store.ts
203
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 <folder>/<id>/
|
||||
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<Account[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(accountJsonPath, 'utf8')
|
||||
@@ -40,181 +21,3 @@ export async function readAccounts(): Promise<Account[]> {
|
||||
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<void> {
|
||||
await fs.mkdir(foldersDir, { recursive: true })
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<string[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<VideoMeta[]> {
|
||||
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<VideoMeta | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user