Files
AriWeb-Suite/modules/webmaker/lib/uploads.js

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 };