Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1,94 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user