v4.3.19-22: 탐정 앱 증거자료 파일/폴더 다운로드 버튼 + 관련 사이트 링크 추가

파일별 다운로드(download 속성), 폴더 전체 zip 다운로드(archiver 신규
의존성 + /api/detective/files/download-folder 라우트) 추가. 사이드바에
법률 서식(대한법률구조공단)·법원 소송 서식(전자소송포털)·대법원 판례
검색(사법정보공개포털)·경찰청 링크 블록 신설.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-28 13:21:59 +09:00
co-authored by Claude Sonnet 5
parent e39c430485
commit 4c3d90c3d2
4 changed files with 1051 additions and 5 deletions
+1001 -2
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -40,6 +40,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"archiver": "^7.0.1",
"avr8js": "^0.21.0",
"better-sqlite3": "^12.9.0",
"chromadb": "^3.5.0",
@@ -63,6 +64,7 @@
"ws": "^8.20.0"
},
"devDependencies": {
"@types/archiver": "^6.0.3",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
+33
View File
@@ -1,6 +1,7 @@
import express from 'express';
import fs from 'fs';
import path from 'path';
import archiver from 'archiver';
import { getUserWorkspace } from '../../config/config';
import { openInNextcloudEditor, fetchFromNextcloudEditor, odtBufferToPdf } from './nextcloud-webdav';
@@ -235,6 +236,38 @@ export function registerDetectiveRoutes(
res.json({ success: true });
});
// Zips a folder-upload's contents on the fly so the whole thing can be
// downloaded in one shot instead of file-by-file — same relPath/caseId
// shape as the delete-folder call above, just GET (plain link, not fetch)
// so the browser handles the streamed attachment response directly.
app.get('/api/detective/files/download-folder', (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
const caseId = String(req.query.caseId || '');
const folder = String(req.query.folder || '');
const segments = folder.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
res.status(400).json({ error: 'invalid' }); return;
}
const dir = detectiveFilesDir(session.username, caseId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) { res.status(403).json({ error: 'Forbidden' }); return; }
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
res.status(404).json({ error: 'Folder not found' }); return;
}
const zipName = `${segments[segments.length - 1]}.zip`;
const encodedName = encodeURIComponent(zipName);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}"; filename*=UTF-8''${encodedName}`);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err: Error) => {
if (!res.headersSent) res.status(500).json({ error: String(err.message || err) });
});
archive.pipe(res);
archive.directory(targetPath, false);
archive.finalize();
});
// Overwrites a text evidence file (.txt/.md/.csv) with UTF-8 content from the
// built-in text editor -- lets legacy EUC-KR/CP949 documents be normalized to
// UTF-8 on save instead of staying garbled forever.
+15 -3
View File
@@ -307,6 +307,15 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<button class="dt-qbtn" onclick="sendQuick('임금 체불 피해를 받았습니다. 근로기준법상 임금 체불의 법적 정의, 고소 절차, 체당금 신청 방법, 사업주 처벌 수위를 알려주세요. 관련 법령도 찾아주세요.')">💼 임금 체불 대응</button>
<button class="dt-qbtn" onclick="sendQuick('현재 사건에 적용 가능한 법령과 판례를 조사해서 법적 대응 방향을 제시해주세요.',true)">🔍 현재 사건 법률 검토</button>
</div>
<div class="dt-sb-divider"></div>
<div class="dt-quick-sec">🔗 관련 사이트</div>
<div class="dt-quick">
<a class="dt-qbtn" href="https://www.klac.or.kr/legalinfo/legalFrm.do" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="고소장, 내용증명 등 실무 법률서식 (대한법률구조공단)">📄 법률 서식 다운로드 (대한법률구조공단)</a>
<a class="dt-qbtn" href="https://ecfs.scourt.go.kr/psp/index.on" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="소장, 답변서 등 민사소송 서식 (전자소송포털)">🏛 법원 (전자소송포털)</a>
<a class="dt-qbtn" href="https://portal.scourt.go.kr/pgp/index.on?m=PGP1011M01&l=N&c=900" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="대법원 판례 검색 (사법정보공개포털)">⚖️ 대법원 판례 검색</a>
<a class="dt-qbtn" href="https://www.police.go.kr" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="경찰청 홈페이지">👮 경찰청</a>
</div>
</aside>
<!-- ── Center: Case notepad ── -->
@@ -1356,6 +1365,7 @@ function renderFileRow(f,name){
<button class="dt-cl-del" onclick="dtFetchFromNextcloud('${escJsAttr(f.relPath)}',this)" title="NextCloud에서 편집 중인(또는 편집 완료한) 최신본을 사건 폴더로 가져옵니다(odt+PDF).">⬇️</button>`:''}
${(f.category==='photo'||isText)?`<button class="dt-cl-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)">🕐+</button>
<button class="dt-cl-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="날짜 인식">📅</button>`:''}
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dt-cl-del" title="기기에 다운로드">💾</a>
<button class="dt-cl-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
</div>`;
}
@@ -1370,8 +1380,9 @@ function renderCategoryGroups(files){
return title+`<div class="dt-ev-photo-grid">${items.map(f=>`
<div class="dt-ev-photo">
<a href="${escAttr(f.url)}" target="_blank" title="${escAttr(f.name)}"><img src="${escAttr(f.url)}" loading="lazy" alt="${escAttr(f.name)}"></a>
<button class="dt-ev-photo-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)" style="right:50px">🕐+</button>
<button class="dt-ev-photo-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="사진에서 날짜 인식" style="right:26px">📅</button>
<button class="dt-ev-photo-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)" style="right:74px">🕐+</button>
<button class="dt-ev-photo-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="사진에서 날짜 인식" style="right:50px">📅</button>
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dt-ev-photo-del" title="기기에 다운로드" style="right:26px">💾</a>
<button class="dt-ev-photo-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
</div>`).join('')}</div>`;
}
@@ -1482,6 +1493,7 @@ function renderFiles(files){
<button class="dt-cl-del" onclick="event.stopPropagation();dtAddFilesToFolder('${escJsAttr(folder)}')" title="이 폴더에 파일 추가" style="opacity:.6;margin-right:8px">+ 파일 추가</button>
<button class="dt-cl-del" onclick="event.stopPropagation();dtFolderRecognize('${escJsAttr(folder)}',this)" title="폴더 내 사진 전체 날짜 인식 (타임라인 탭에서 확인 후 일괄 추가)" style="opacity:.6;margin-right:8px">🕐+ 폴더 인식</button>
<button class="dt-cl-del" onclick="event.stopPropagation();dtToggleFolderOrder('${escJsAttr(folder)}')" title="폴더 내 파일 정렬 순서 전환" style="opacity:.6;margin-right:8px">${order==='asc'?'🔼 오래된순':'🔽 최신순'}</button>
<a href="/api/detective/files/download-folder?caseId=${encodeURIComponent(currentCaseId)}&folder=${encodeURIComponent(folder)}" onclick="event.stopPropagation()" class="dt-cl-del" title="폴더 전체를 zip으로 다운로드" style="opacity:.6;margin-right:8px">💾 폴더 다운로드</a>
<button class="dt-cl-del" onclick="event.stopPropagation();deleteFolder('${escJsAttr(folder)}')" title="폴더 전체 삭제" style="opacity:.6">🗑 폴더 삭제</button>
</span>
</div><div class="dt-ev-folder-body"${collapsed?' style="display:none"':''}>`+renderFolderContents(items,order)+`</div>`;
@@ -1964,7 +1976,7 @@ triggers: [조사, 탐문, 인물, 기업, 사건, 단서, 증거, 추적, 사
| 뉴스 | \`site:news.naver.com\` | 네이버 뉴스 |
| 해외 기업 | \`site:opencorporates.com\` | 글로벌 법인 검색 |
| 한국 법령 원문 | \`site:law.go.kr\` | 국가법령정보센터 |
| 대법원 판례 | \`site:glaw.scourt.go.kr\` | 대법원 종합법률정보 |
| 대법원 판례 | \`site:portal.scourt.go.kr\` | 사법정보공개포털 |
---