Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
95 lines
4.5 KiB
JavaScript
95 lines
4.5 KiB
JavaScript
'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 → 텍스트 추출본(<name>.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<Array<{name,rel,size,kind,mime,textSidecar,addedAt,usedInCommands}>>}
|
|
*/
|
|
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 };
|