'use strict'; // 첨부 파일(이미지/문서) 저장·목록·삭제. 파일은 사이트 루트의 uploads/ 에 두어 // nginx 로 바로 서비스되고, Claude 가 Read 도구로 읽거나 상대 경로로 페이지에 삽입할 수 있다. const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const UPLOAD_DIR = 'uploads'; const MAX_FILE_BYTES = 25 * 1024 * 1024; const MAX_FILES_PER_REQUEST = 20; const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'avif']); const DOC_EXT = new Set(['txt', 'md', 'markdown', 'csv', 'tsv', 'json', 'html', 'htm', 'pdf', 'docx', 'xml', 'yaml', 'yml', 'rtf']); function extOf(name) { return (path.extname(name).slice(1) || '').toLowerCase(); } function kindOf(name) { const e = extOf(name); if (IMAGE_EXT.has(e)) return 'image'; if (DOC_EXT.has(e)) return 'document'; return null; } function allowedExtensions() { return [...IMAGE_EXT, ...DOC_EXT]; } function sanitizeName(name) { let base = path.basename(String(name || 'file')).normalize('NFC'); base = base.replace(/[^\w.\-가-힣 ()]/g, '_').replace(/\s+/g, ' ').trim(); base = base.replace(/^\.+/, '').replace(/\.+$/, ''); if (!base) base = 'file'; const ext = extOf(base); let stem = ext ? base.slice(0, -(ext.length + 1)) : base; if (stem.length > 60) stem = stem.slice(0, 60); return ext ? `${stem}.${ext}` : stem; } function uniquePath(dir, name) { const ext = extOf(name); const stem = ext ? name.slice(0, -(ext.length + 1)) : name; let candidate = name, i = 2; while (fs.existsSync(path.join(dir, candidate))) { candidate = ext ? `${stem}-${i}.${ext}` : `${stem}-${i}`; i += 1; } return candidate; } // docx → 텍스트 추출본(.txt) 생성. 실패해도 업로드는 유지. function extractDocxText(filePath) { // v1.3.17 (B15): 추출본 이름도 업로드 파일과 같은 규칙으로 겹치지 않게 정한다 (같은 이름의 .txt 첨부를 덮어쓰던 문제) const out = path.join(path.dirname(filePath), uniquePath(path.dirname(filePath), `${path.basename(filePath)}.txt`)); try { const script = 'import sys,docx\nd=docx.Document(sys.argv[1])\nparts=[p.text for p in d.paragraphs]\nfor t in d.tables:\n for r in t.rows:\n parts.append("\\t".join(c.text for c in r.cells))\nopen(sys.argv[2],"w",encoding="utf-8").write("\\n".join(parts))\n'; execFileSync('python3', ['-c', script, filePath, out], { timeout: 30000, stdio: 'ignore' }); fs.chmodSync(out, 0o644); return path.basename(out); } catch (_) { return null; } } /** * multipart 로 받은 File 객체들을 저장한다. * @returns {Promise>} */ async function saveUploads(siteDir, files) { if (!files || !files.length) return []; if (files.length > MAX_FILES_PER_REQUEST) throw Object.assign(new Error(`한 번에 최대 ${MAX_FILES_PER_REQUEST}개까지 업로드할 수 있습니다`), { status: 400 }); const dir = path.join(siteDir, UPLOAD_DIR); fs.mkdirSync(dir, { recursive: true, mode: 0o755 }); const saved = []; for (const f of files) { if (!f || typeof f.arrayBuffer !== 'function' || !f.name) continue; const kind = kindOf(f.name); if (!kind) throw Object.assign(new Error(`지원하지 않는 파일 형식입니다: ${f.name} (허용: ${allowedExtensions().join(', ')})`), { status: 400 }); if (f.size > MAX_FILE_BYTES) throw Object.assign(new Error(`파일이 너무 큽니다 (최대 25MB): ${f.name}`), { status: 400 }); const name = uniquePath(dir, sanitizeName(f.name)); const fp = path.join(dir, name); fs.writeFileSync(fp, Buffer.from(await f.arrayBuffer()), { mode: 0o644 }); const rec = { name, rel: `${UPLOAD_DIR}/${name}`, size: f.size, kind, mime: f.type || '', textSidecar: null, addedAt: new Date().toISOString(), usedInCommands: [] }; if (extOf(name) === 'docx') { const side = extractDocxText(fp); if (side) rec.textSidecar = `${UPLOAD_DIR}/${side}`; } saved.push(rec); } return saved; } function deleteUpload(siteDir, rec) { for (const rel of [rec.rel, rec.textSidecar]) { if (!rel) continue; const fp = path.join(siteDir, rel); if (fp.startsWith(path.join(siteDir, UPLOAD_DIR) + path.sep)) { try { fs.unlinkSync(fp); } catch (_) {} } } } function humanSize(n) { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`; return `${(n / 1024 / 1024).toFixed(1)}MB`; } module.exports = { UPLOAD_DIR, MAX_FILE_BYTES, saveUploads, deleteUpload, allowedExtensions, humanSize };