diff --git a/src/gateway/routes-detective-dates.ts b/src/gateway/routes-detective-dates.ts new file mode 100644 index 0000000..765a0bb --- /dev/null +++ b/src/gateway/routes-detective-dates.ts @@ -0,0 +1,449 @@ +/** + * routes-detective-dates.ts + * Detective app: recognize the date shown inside an evidence photo (EXIF + * DateTimeOriginal first, falling back to a vision-LLM read of any visible + * on-screen date — chat timestamps, receipt dates, etc.) and store it as a + * pending suggestion on the case for the user to approve into the timeline. + */ +import express from 'express'; +import path from 'path'; +import fs from 'fs'; +import { spawn } from 'child_process'; +import iconv from 'iconv-lite'; +import { getUserWorkspace } from '../config/config'; +import { getOllamaClient } from '../agents/ollama-client'; + +const PHOTO_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'tiff']); +const TEXT_DOC_EXTS = new Set(['txt', 'md', 'csv']); +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const MAX_TEXT_DOC_BYTES = 5 * 1024 * 1024; // reading is synchronous; cap so a huge file can't block the event loop + +function extOf(relPath: string): string { + return path.extname(relPath).slice(1).toLowerCase(); +} + +// The regex alone can't catch "2025-13-45" (bad month/day) — round-trip +// through Date and check the parts survived, which a real calendar date does +// and a bogus one (invalid month/day, or JS's day-rollover) does not. +function isValidCalendarDate(date: string): boolean { + if (!DATE_RE.test(date)) return false; + const [y, m, d] = date.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d; +} + +function isPathInsideDir(base: string, target: string): boolean { + const resolvedBase = path.resolve(base); + const resolvedTarget = path.resolve(target); + if (resolvedBase === resolvedTarget) return true; + const rel = path.relative(resolvedBase, resolvedTarget); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} + +function detectiveFilesDir(username: string, caseId: string): string { + return path.join(getUserWorkspace(username), 'detective', caseId); +} + +function detectiveCasesPath(username: string): string { + return path.join(getUserWorkspace(username), '.smallclaw', 'detective-cases.json'); +} + +function readExifDate(imgPath: string): Promise { + const script = ` +import json +try: + import pi_heif; pi_heif.register_heif_opener() +except Exception: + pass +try: + from PIL import Image, ExifTags + img = Image.open(${JSON.stringify(imgPath)}) + raw = img._getexif() or {} + exif = {ExifTags.TAGS.get(k, str(k)): v for k, v in raw.items() if k in ExifTags.TAGS} + dt = exif.get("DateTimeOriginal") or exif.get("DateTime") + print(json.dumps({"dt": str(dt) if dt else None})) +except Exception as e: + print(json.dumps({"dt": None, "error": str(e)})) +`; + return new Promise((resolve) => { + let out = ''; + try { + const proc = spawn('python3', ['-c', script]); + proc.stdout.on('data', (d: Buffer) => { out += d.toString(); }); + proc.on('close', () => { + try { + const parsed = JSON.parse(out.trim().split('\n').pop() || '{}'); + const m = parsed.dt ? String(parsed.dt).match(/^(\d{4})[:-](\d{2})[:-](\d{2})/) : null; + resolve(m ? `${m[1]}-${m[2]}-${m[3]}` : undefined); + } catch { resolve(undefined); } + }); + proc.on('error', () => resolve(undefined)); + } catch { resolve(undefined); } + }); +} + +const MONTH_DAY_RE = /^\d{2}-\d{2}$/; + +// KakaoTalk chat lists show "8월 15일" style dates with no year anywhere, so +// forcing the model to answer a full YYYY-MM-DD often produces a false null. +// month_day lets it report what it actually saw; the caller combines that +// with the file's EXIF year as a best guess, which the user can still edit. +// The same call also asks for a short content label (used to rename the file +// when its original name is a generic camera/screenshot name) — for maps, +// the model is told to name the pinned/marked location instead. +async function readVisionDate(imgPath: string): Promise<{ date?: string; monthDay?: string; reason?: string; label?: string }> { + const ext = path.extname(imgPath).slice(1).toLowerCase(); + const mime = ext === 'jpg' ? 'jpeg' : ext; + const b64 = fs.readFileSync(imgPath).toString('base64'); + const prompt = '이 이미지는 사건 증거자료(카카오톡 대화 캡처, 문자메시지, 영수증, 지도, 문서 사진 등)입니다. ' + + '이미지 안에 실제로 보이는 날짜(대화창 날짜 구분선, 메시지 타임스탬프, 영수증/문서에 인쇄된 날짜 등)를 찾고, ' + + '내용을 요약하는 짧은 한국어 라벨도 함께 만들어 다음 JSON 형식으로만 답하세요 (코드블록 없이 순수 JSON만): ' + + '{"date":"연도까지 정확히 보이면 YYYY-MM-DD, 아니면 null",' + + '"month_day":"연도는 안 보이지만 월/일은 보이면 MM-DD, 아니면 null",' + + '"reason":"어디서 어떤 날짜를 읽었는지 한 줄 설명",' + + '"label":"이미지 내용을 요약하는 2~6단어 내외의 짧은 한국어 표현 (예: 카톡_엄마와의_대화, 영수증_스타벅스, 문자_협박메시지). 지도 화면이고 위치 마커/핀이 있으면 마커가 가리키는 장소명을 반드시 포함 (예: 지도_강남역_인근)"} ' + + '카카오톡 채팅 목록처럼 연도 없이 "8월 15일"만 보이는 경우 date는 null, month_day는 "08-15"로 답하세요. ' + + '날짜를 아무것도 찾을 수 없으면 date, month_day 둘 다 null로 답하되 label은 항상 채우세요.'; + + // Never throws — a timeout/network error from the cloud model must not take + // down the whole suggestion (readExifDate runs alongside this in + // Promise.all, and an EXIF-only fallback is still useful even when vision + // is unavailable). + try { + const ollama = getOllamaClient(); + const result = await ollama.chatWithThinking( + [{ + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: `data:image/${mime};base64,${b64}` } }, + ], + }], + 'executor', + { model: 'kimi-k2.6:cloud', num_predict: 1500, temperature: 0.1 }, + ); + const raw = String(result.message?.content || '').trim(); + const m = raw.match(/\{[\s\S]*\}/); + const parsed = JSON.parse(m ? m[0] : raw); + const date = parsed?.date && isValidCalendarDate(parsed.date) ? parsed.date : undefined; + const monthDay = parsed?.month_day && MONTH_DAY_RE.test(parsed.month_day) ? parsed.month_day : undefined; + const label = parsed?.label ? String(parsed.label).trim().slice(0, 60) : undefined; + return { date, monthDay, reason: parsed?.reason ? String(parsed.reason) : undefined, label }; + } catch { + return {}; + } +} + +// Old Korean text evidence is often EUC-KR, not UTF-8 (same issue the +// built-in text editor in detective-app.html handles client-side). Only the +// first MAX_TEXT_DOC_BYTES are ever read — readFileSync is synchronous and a +// huge accidental upload would otherwise block the event loop just to have +// its content truncated to 6000 chars a moment later anyway. +function decodeTextFile(filePath: string): string { + const size = fs.statSync(filePath).size; + const fd = fs.openSync(filePath, 'r'); + let buf: Buffer; + try { + const len = Math.min(size, MAX_TEXT_DOC_BYTES); + buf = Buffer.alloc(len); + fs.readSync(fd, buf, 0, len, 0); + } finally { + fs.closeSync(fd); + } + try { + const s = buf.toString('utf-8'); + if (!s.includes('�')) return s; + } catch {} + try { return iconv.decode(buf, 'euc-kr'); } catch { return buf.toString('utf-8'); } +} + +// Text documents have no EXIF and nothing to "look at", so this reads the +// actual content and asks the model to find a date mentioned in the text +// (a letter/report date, a diary entry, an invoice date, etc.) the same way +// readVisionDate does for photos — same JSON contract, same date/month_day/ +// reason/label fields, so the rest of the pipeline doesn't need to care +// whether a suggestion came from a photo or a document. +async function readTextDate(filePath: string): Promise<{ date?: string; monthDay?: string; reason?: string; label?: string }> { + const content = decodeTextFile(filePath).slice(0, 6000); + const prompt = '이 텍스트는 사건 증거자료 문서입니다. 내용 중에 실제로 언급된 날짜(작성일, 사건 발생일, 일기/메모의 날짜 등)를 찾고, ' + + '내용을 요약하는 짧은 한국어 라벨도 함께 만들어 다음 JSON 형식으로만 답하세요 (코드블록 없이 순수 JSON만): ' + + '{"date":"연도까지 정확히 확인되면 YYYY-MM-DD, 아니면 null",' + + '"month_day":"연도는 불명확하지만 월/일은 확인되면 MM-DD, 아니면 null",' + + '"reason":"어떤 문구에서 날짜를 읽었는지 한 줄 설명",' + + '"label":"문서 내용을 요약하는 2~6단어 내외의 짧은 한국어 표현 (예: 메모_병원방문기록, 진술서_초안)"} ' + + '날짜를 아무것도 찾을 수 없으면 date, month_day 둘 다 null로 답하되 label은 항상 채우세요.\n\n--- 문서 내용 ---\n' + content; + + // Never throws — see readVisionDate's comment on the same pattern. + try { + const ollama = getOllamaClient(); + const result = await ollama.chatWithThinking( + [{ role: 'user', content: prompt }], + 'executor', + { model: 'kimi-k2.6:cloud', num_predict: 1500, temperature: 0.1 }, + ); + const raw = String(result.message?.content || '').trim(); + const m = raw.match(/\{[\s\S]*\}/); + const parsed = JSON.parse(m ? m[0] : raw); + const date = parsed?.date && isValidCalendarDate(parsed.date) ? parsed.date : undefined; + const monthDay = parsed?.month_day && MONTH_DAY_RE.test(parsed.month_day) ? parsed.month_day : undefined; + const label = parsed?.label ? String(parsed.label).trim().slice(0, 60) : undefined; + return { date, monthDay, reason: parsed?.reason ? String(parsed.reason) : undefined, label }; + } catch { + return {}; + } +} + +// Generic auto-generated camera/screenshot names carry no information, so the +// content label is used instead when renaming. A name the user (or the phone) +// already gave meaning to — like "엄마 8-7.jpg" — is left untouched. +function isGenericFileName(stem: string): boolean { + const s = stem.trim(); + if (!s) return true; + const compact = s.replace(/[\s_\-.]+/g, ''); + if (/^\d+$/.test(compact)) return true; // pure timestamp/number + if (/^[0-9a-f]{6,}$/i.test(compact)) return true; // hash-like + const genericPrefixes = /^(img|image|photo|pic|dsc|dcim|pxl|vid|video|mov|screenshot|scrshot|kakaotalk|kakao|scr|frame|snapshot|capture|save|received|inboundshare|스크린샷|캡처|사진|카카오톡)/i; + return genericPrefixes.test(compact); +} + +function sanitizeFileNameSegment(seg: string): string { + const s = String(seg).replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, '_').trim(); + if (!s || s === '.' || s === '..') return '_'; + return s; +} + +// A file already renamed by this pipeline starts with "YYYY-MM-DD_" (or "-", +// in case it was ever produced by a manually-typed name). Re-running date +// recognition/commit on it — e.g. the user re-triggers 🕐+ on an already +// processed file — must not stack another date prefix on top of the old one. +// The regex alone can't tell a pipeline-generated prefix apart from a user's +// own "2024-01-15-영수증.jpg"-style name, so it's only trusted when `relPath` +// is already a relPath the pipeline itself recorded (an existing timeline +// entry or a still-pending suggestion) — otherwise the name is left whole. +const EXISTING_DATE_PREFIX_RE = /^\d{4}-\d{2}-\d{2}[_-](.+)$/; +function stripExistingDatePrefix(stem: string, relPath: string, c: any): string { + const m = stem.match(EXISTING_DATE_PREFIX_RE); + if (!m) return stem; + const knownToUs = Array.isArray(c?.timeline) && c.timeline.some((t: any) => t.relPath === relPath) + || Array.isArray(c?.dateSuggestions) && c.dateSuggestions.some((s: any) => s.relPath === relPath); + return knownToUs ? m[1] : stem; +} + +interface DateSuggestion { + id: string; + relPath: string; + fileName: string; + url: string; + kind: 'photo' | 'text'; + date: string | null; + source: 'exif' | 'vision' | 'vision+exif_year' | 'text'; + reason: string | null; + label: string | null; + nameOverride: string; + createdAt: string; +} + +function readCasesStore(casesPath: string): { cases: any[] } { + let store: { cases: any[] } = { cases: [] }; + try { if (fs.existsSync(casesPath)) store = JSON.parse(fs.readFileSync(casesPath, 'utf-8')); } catch {} + return store; +} + +async function suggestPhotoDate(username: string, caseId: string, relPath: string): Promise { + const dir = detectiveFilesDir(username, caseId); + const segments = relPath.split('/').filter(Boolean); + const absPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, absPath) || !fs.existsSync(absPath)) throw new Error('file not found'); + const isText = TEXT_DOC_EXTS.has(extOf(relPath)); + const caseAtStart = (readCasesStore(detectiveCasesPath(username)).cases || []).find((x: any) => x.id === caseId); + + let date: string | undefined; + let source: 'exif' | 'vision' | 'vision+exif_year' | 'text' = isText ? 'text' : 'vision'; + let reason: string | undefined; + let contentLabel: string | undefined; + + if (isText) { + const text = await readTextDate(absPath); + date = text.date; + reason = text.reason; + contentLabel = text.label; + // Text documents rarely have a year-less date the way KakaoTalk chat + // lists do, and there's no EXIF to guess a year from, so month_day alone + // isn't used here — it just falls through to no date if that's all there is. + } else { + // Vision first: for chat/message screenshots the file's own EXIF timestamp + // is when the screenshot was saved, not when the conversation happened, so + // the date printed inside the image is the one that actually matters. EXIF + // is only used as a fallback (plain photos with no date text) or to guess + // the year when the screenshot shows month/day but no year (KakaoTalk never + // shows the year in its chat list). + const [vision, exifDate] = await Promise.all([readVisionDate(absPath), readExifDate(absPath)]); + contentLabel = vision.label; + if (vision.date) { + date = vision.date; + reason = vision.reason; + } else if (vision.monthDay && exifDate) { + date = `${exifDate.slice(0, 4)}-${vision.monthDay}`; + source = 'vision+exif_year'; + reason = `${vision.reason ? vision.reason + ' ' : ''}(연도는 이미지에 없어 파일 메타데이터의 연도로 추정 — 반드시 확인하세요)`.trim(); + } else if (exifDate) { + date = exifDate; + source = 'exif'; + reason = vision.reason; + } else { + reason = vision.reason; + } + } + + // Default "what will the new name look like" shown to the user for review — + // a meaningful original name (e.g. "엄마 8-7.jpg") is kept as-is, a generic + // camera/screenshot name is replaced by the AI's content label. Either way + // it's just a prefill: the user can type over it before committing. + const originalStem = stripExistingDatePrefix(path.basename(relPath, path.extname(relPath)), relPath, caseAtStart); + const nameOverride = (isGenericFileName(originalStem) && contentLabel) ? contentLabel : originalStem; + + const suggestion: DateSuggestion = { + id: 'sug_' + Math.random().toString(36).slice(2, 10), + relPath, + fileName: path.basename(relPath), + url: `/api/files/detective/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`, + kind: isText ? 'text' : 'photo', + date: date || null, + source, + reason: reason || null, + label: contentLabel || null, + nameOverride, + createdAt: new Date().toISOString(), + }; + + const casesPath = detectiveCasesPath(username); + const store = readCasesStore(casesPath); + const c = (store.cases || []).find((x: any) => x.id === caseId); + if (c) { + if (!Array.isArray(c.dateSuggestions)) c.dateSuggestions = []; + c.dateSuggestions = c.dateSuggestions.filter((s: any) => s.relPath !== relPath); + c.dateSuggestions.push(suggestion); + fs.mkdirSync(path.dirname(casesPath), { recursive: true }); + const tmp = `${casesPath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8'); + fs.renameSync(tmp, casesPath); + } + + return suggestion; +} + +// Renames the photo to "_.", copies the +// untouched original into a "원본/" subfolder next to it (so a wrong guess +// never loses the source file), and records the date on the case timeline — +// all as one action, since the file only gets touched once the user commits. +async function commitPhotoToTimeline( + username: string, caseId: string, relPath: string, date: string, label?: string, +): Promise<{ relPath: string; name: string; url: string; text: string; id: string }> { + if (!isValidCalendarDate(date)) throw new Error('invalid date'); + const dir = detectiveFilesDir(username, caseId); + const segments = relPath.split('/').filter(Boolean); + const absPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, absPath) || !fs.existsSync(absPath)) throw new Error('file not found'); + + const casesPath = detectiveCasesPath(username); + const store = readCasesStore(casesPath); + const c = (store.cases || []).find((x: any) => x.id === caseId); + + const fileDir = path.dirname(absPath); + const folderRel = segments.slice(0, -1).join('/'); + const originalName = segments[segments.length - 1]; + const ext = path.extname(originalName); + const originalStem = stripExistingDatePrefix(path.basename(originalName, ext), relPath, c); + + // `label` here is whatever the user left in the editable name field (it + // starts prefilled with a sensible default, see nameOverride above, but the + // caller may have edited or cleared it) — an explicit non-empty value always + // wins; empty means "no override, keep the original file name". Either way + // stripExistingDatePrefix above keeps a re-commit from stacking dates. + const core = label && label.trim() + ? sanitizeFileNameSegment(stripExistingDatePrefix(label.trim(), relPath, c)) + : sanitizeFileNameSegment(originalStem); + let newName = `${date}_${core}${ext}`; + let newAbsPath = path.join(fileDir, newName); + let n = 2; + while (fs.existsSync(newAbsPath) && newAbsPath !== absPath) { + newName = `${date}_${core}_${n}${ext}`; + newAbsPath = path.join(fileDir, newName); + n++; + } + + const backupDir = path.join(fileDir, '원본'); + fs.mkdirSync(backupDir, { recursive: true }); + let backupPath = path.join(backupDir, originalName); + let bn = 2; + while (fs.existsSync(backupPath)) { + backupPath = path.join(backupDir, `${path.basename(originalName, ext)}_${bn}${ext}`); + bn++; + } + fs.copyFileSync(absPath, backupPath); + fs.renameSync(absPath, newAbsPath); + + const newRelPath = (folderRel ? `${folderRel}/` : '') + newName; + const newUrl = `/api/files/detective/${caseId}/${newRelPath.split('/').map(encodeURIComponent).join('/')}`; + + const icon = TEXT_DOC_EXTS.has(extOf(newName)) ? '📄' : '📷'; + const text = `${icon} ${newName}`; + const id = 'tl_' + Math.random().toString(36).slice(2, 8); + + if (c) { + if (!Array.isArray(c.timeline)) c.timeline = []; + c.timeline.push({ id, date, text, relPath: newRelPath, url: newUrl }); + if (Array.isArray(c.dateSuggestions)) { + c.dateSuggestions = c.dateSuggestions.filter((s: any) => s.relPath !== relPath); + } + fs.mkdirSync(path.dirname(casesPath), { recursive: true }); + const tmp = `${casesPath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8'); + fs.renameSync(tmp, casesPath); + } + + return { relPath: newRelPath, name: newName, url: newUrl, text, id }; +} + +export function registerDetectiveDateRoutes(app: express.Application, getSessionUser: (req: express.Request) => any): void { + app.post('/api/detective/files/date-suggest', async (req, res) => { + const session = getSessionUser(req); + if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; } + const caseId = String(req.body?.caseId || ''); + const relPath = String(req.body?.relPath || ''); + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some((s: string) => s === '..' || s === '.')) { + res.status(400).json({ error: 'invalid' }); return; + } + const ext = extOf(relPath); + if (!PHOTO_EXTS.has(ext) && !TEXT_DOC_EXTS.has(ext)) { res.status(400).json({ error: 'unsupported file type' }); return; } + + try { + const suggestion = await suggestPhotoDate(session.username, caseId, relPath); + res.json({ success: true, suggestion }); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); + } + }); + + app.post('/api/detective/files/add-to-timeline', async (req, res) => { + const session = getSessionUser(req); + if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; } + const caseId = String(req.body?.caseId || ''); + const relPath = String(req.body?.relPath || ''); + const date = String(req.body?.date || ''); + const label = req.body?.label ? String(req.body.label) : undefined; + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some((s: string) => s === '..' || s === '.')) { + res.status(400).json({ error: 'invalid' }); return; + } + if (!isValidCalendarDate(date)) { res.status(400).json({ error: 'invalid date' }); return; } + + try { + const result = await commitPhotoToTimeline(session.username, caseId, relPath, date, label); + res.json({ success: true, ...result }); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); + } + }); +} diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index eaede09..aac9e4a 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -23,6 +23,7 @@ import { registerMusicRoutes } from './routes-music'; import { registerMCPRoutes } from './routes-mcp'; import { registerLanguageRoutes } from './routes-language'; import { registerDentalRoutes } from './routes-dental'; +import { registerDetectiveDateRoutes } from './routes-detective-dates'; import { getConfig, getAgents, @@ -9334,6 +9335,204 @@ app.put('/api/detective/cases', (req, res) => { res.json({ success: true }); }); +// ─── Per-case Detective File Attachments ───────────────────────────────────── +// Stored at /detective//, served via /api/files/ + +function detectiveFilesDir(username: string, caseId: string): string { + return path.join(getUserWorkspace(username), 'detective', caseId); +} +function sanitizeDetectivePathSegment(seg: string): string { + const s = seg.replace(/[\x00-\x1f\x7f:*?"<>|]/g, '_').trim(); + if (!s || s === '.' || s === '..') return '_'; + return s; +} +// Folder uploads send a relative path (e.g. "2024/IMG001.jpg") as the +// filename. Each path segment is sanitized and the real directory structure +// is recreated under the case folder, so a whole subfolder can later be +// deleted as a unit instead of only file-by-file. +function resolveDetectiveUploadPath(baseDir: string, originalName: string): { relPath: string; absPath: string } { + const rawSegments = String(originalName || 'upload').split(/[\\/]+/).filter(Boolean); + const segments = (rawSegments.length ? rawSegments : ['upload']).map(sanitizeDetectivePathSegment); + const folderSegs = segments.slice(0, -1); + let fileSeg = segments[segments.length - 1] || 'upload'; + const dir = path.join(baseDir, ...folderSegs); + fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(path.join(dir, fileSeg))) { + const ext = path.extname(fileSeg); + const stem = path.basename(fileSeg, ext); + fileSeg = `${stem}_${Date.now()}${ext}`; + } + const relPath = [...folderSegs, fileSeg].join('/'); + return { relPath, absPath: path.join(dir, fileSeg) }; +} +function walkDetectiveFiles(dir: string, relBase = ''): Array<{ relPath: string; name: string; size: number; mtime: string }> { + if (!fs.existsSync(dir)) return []; + const out: Array<{ relPath: string; name: string; size: number; mtime: string }> = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name === '원본') continue; // backups of renamed photos — kept on disk, hidden from the evidence list + const abs = path.join(dir, entry.name); + const rel = relBase ? `${relBase}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + out.push(...walkDetectiveFiles(abs, rel)); + } else if (entry.isFile()) { + const stat = fs.statSync(abs); + out.push({ relPath: rel, name: entry.name, size: stat.size, mtime: stat.mtime.toISOString() }); + } + } + return out; +} +const DETECTIVE_FILE_CATEGORIES: Record = { + jpg: 'photo', jpeg: 'photo', png: 'photo', gif: 'photo', webp: 'photo', bmp: 'photo', heic: 'photo', heif: 'photo', tiff: 'photo', + mp4: 'video', mov: 'video', avi: 'video', mkv: 'video', webm: 'video', m4v: 'video', wmv: 'video', + pdf: 'document', doc: 'document', docx: 'document', xls: 'document', xlsx: 'document', ppt: 'document', pptx: 'document', txt: 'document', hwp: 'document', hwpx: 'document', +}; +function detectiveFileCategory(name: string): string { + const ext = path.extname(name).slice(1).toLowerCase(); + return DETECTIVE_FILE_CATEGORIES[ext] || 'other'; +} + +app.get('/api/detective/files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const caseId = String(req.query.caseId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) return res.status(400).json({ error: 'invalid caseId' }); + const dir = detectiveFilesDir(session.username, caseId); + const files = walkDetectiveFiles(dir).map(e => { + const segs = e.relPath.split('/'); + return { + name: e.name, + relPath: e.relPath, + folder: segs.length > 1 ? segs[0] : '', + size: e.size, + mtime: e.mtime, + url: `/api/files/detective/${caseId}/${segs.map(encodeURIComponent).join('/')}`, + category: detectiveFileCategory(e.name), + }; + }); + res.json({ files }); +}); + +app.post('/api/detective/upload', (req, res) => { + const session = getSessionUser(req); + if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } + const caseId = String(req.query.caseId || ''); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) { res.status(400).json({ success: false, error: 'invalid caseId' }); return; } + const contentType = String(req.headers['content-type'] || ''); + if (!contentType.includes('multipart/form-data')) { + res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return; + } + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; } + + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('binary'); + const boundaryDelim = '--' + boundary; + + let filename = 'upload.bin'; + let filetype = 'application/octet-stream'; + let fileData: Buffer | null = null; + + const parts = raw.split(boundaryDelim); + for (const part of parts) { + if (!part || part.trim() === '--' || part.trim() === '') continue; + const headerEnd = part.indexOf('\r\n\r\n'); + if (headerEnd === -1) continue; + const header = part.substring(0, headerEnd); + if (!header.includes('name="file"')) continue; + + const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i) + ?? header.match(/filename="([^"]+)"/); + if (fnMatch) { + const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1] + ? Buffer.from(fnMatch[1], 'binary').toString('utf-8') + : decodeURIComponent(fnMatch[1]); + filename = raw8; + } + const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i); + if (ctMatch) filetype = ctMatch[1].trim(); + + const bodyStart = headerEnd + 4; + const bodyEnd = part.lastIndexOf('\r\n'); + if (bodyEnd <= bodyStart) continue; + + fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary'); + break; + } + + if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; } + if (fileData.length > 50 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 50MB)' }); return; } + + const dir = detectiveFilesDir(session.username, caseId); + fs.mkdirSync(dir, { recursive: true }); + const { relPath, absPath } = resolveDetectiveUploadPath(dir, filename); + fs.writeFileSync(absPath, fileData); + + res.json({ + success: true, + name: path.basename(relPath), + relPath, + size: fileData.length, + type: filetype, + url: `/api/files/detective/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`, + category: detectiveFileCategory(relPath), + }); + }); + req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); }); +}); + +app.delete('/api/detective/files', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const caseId = String(req.body?.caseId || ''); + // relPath may point at a single file or a whole subfolder (folder-level + // batch delete); name is kept as a fallback for the old root-file-only shape. + const relPath = String(req.body?.relPath || req.body?.name || ''); + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) { + return res.status(400).json({ error: 'invalid' }); + } + const dir = detectiveFilesDir(session.username, caseId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' }); + if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; } + try { + const stat = fs.statSync(targetPath); + if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true }); + else fs.unlinkSync(targetPath); + } catch (e: any) { + res.status(500).json({ success: false, error: String(e?.message || e) }); return; + } + res.json({ success: true }); +}); + +// 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. +app.put('/api/detective/files/content', (req, res) => { + const session = getSessionUser(req); + if (!session) return res.status(401).json({ error: 'Unauthorized' }); + const caseId = String(req.body?.caseId || ''); + const relPath = String(req.body?.relPath || ''); + const content = req.body?.content; + const segments = relPath.split('/').filter(Boolean); + if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.') || typeof content !== 'string') { + return res.status(400).json({ error: 'invalid' }); + } + const dir = detectiveFilesDir(session.username, caseId); + const targetPath = path.join(dir, ...segments); + if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' }); + if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found' }); + try { + fs.writeFileSync(targetPath, content, 'utf-8'); + const stat = fs.statSync(targetPath); + res.json({ success: true, size: stat.size, mtime: stat.mtime.toISOString() }); + } catch (e: any) { + res.status(500).json({ error: String(e?.message || e) }); + } +}); + // ─── Per-user Investor Watchlist ───────────────────────────────────────────── // Stored at /.smallclaw/investor-watchlist.json @@ -9543,6 +9742,77 @@ app.use('/wavacity', express.static(path.join(webUiPath, 'wavacity'), { } })); +// Entry point for opening the PDF editor scoped to a case: sets the +// dt_pdf_case cookie server-side, then redirects to a clean /pdf-editor/ +// URL with no query string. (Stirling-PDF's root path has a bug where a +// query string on the bare context-path root causes a broken redirect — +// see /pdf-editor below — so the case id is never sent in that request.) +app.get('/pdf-editor-open', (req: express.Request, res: express.Response) => { + const session = getSessionUser(req); + if (!session) { res.redirect('/login.html'); return; } + const caseId = String(req.query.caseId || ''); + if (/^[a-zA-Z0-9_-]+$/.test(caseId)) { + res.setHeader('Set-Cookie', `dt_pdf_case=${caseId}; Path=/pdf-editor; HttpOnly; SameSite=Lax; Max-Age=3600`); + } + res.redirect('/pdf-editor/'); +}); + +// Stirling-PDF — self-hosted PDF editor (Docker, localhost:8090), reverse-proxied +// behind the gateway's own auth. Container runs with SERVER_SERVLET_CONTEXT_PATH= +// /pdf-editor so its self-referencing asset/API URLs match this mount point. +// +// Auto-save: the dt_pdf_case cookie (set by /pdf-editor-open above) ties this +// browser tab to a case. Any response that looks like a finished result (PDF +// content-type + a Content-Disposition filename, as opposed to preview/asset +// traffic) is teed into that case's folder in addition to being streamed to +// the browser as normal, so "download" in the editor also lands in the case. +app.use('/pdf-editor', (req: express.Request, res: express.Response) => { + const cookies = parseCookies(req); + const caseId = cookies['dt_pdf_case'] || ''; + const session = getSessionUser(req); + + const proxyReq = http.request({ + hostname: '127.0.0.1', + port: 8090, + path: req.originalUrl, + method: req.method, + headers: { ...req.headers, host: '127.0.0.1:8090' }, + }, (proxyRes) => { + const ct = String(proxyRes.headers['content-type'] || ''); + const cd = String(proxyRes.headers['content-disposition'] || ''); + const isResult = session && caseId && /^[a-zA-Z0-9_-]+$/.test(caseId) + && ct.includes('application/pdf') && /filename=/i.test(cd); + if (!isResult) { + res.writeHead(proxyRes.statusCode || 502, proxyRes.headers); + proxyRes.pipe(res); + return; + } + const chunks: Buffer[] = []; + proxyRes.on('data', (c: Buffer) => chunks.push(c)); + proxyRes.on('end', () => { + const body = Buffer.concat(chunks); + try { + const dir = detectiveFilesDir(session!.username, caseId); + fs.mkdirSync(dir, { recursive: true }); + let filename = 'edited.pdf'; + const fnStar = cd.match(/filename\*=UTF-8''([^;]+)/i); + const fnPlain = cd.match(/filename="?([^";]+)"?/i); + if (fnStar) { try { filename = decodeURIComponent(fnStar[1]); } catch {} } + else if (fnPlain) filename = fnPlain[1]; + const { relPath, absPath } = resolveDetectiveUploadPath(dir, filename); + fs.writeFileSync(absPath, body); + console.log(`[pdf-editor] saved result to case ${caseId}: ${relPath}`); + } catch (err) { + console.error('[pdf-editor] auto-save failed:', err); + } + res.writeHead(proxyRes.statusCode || 200, proxyRes.headers); + res.end(body); + }); + }); + proxyReq.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'PDF editor unavailable' }); }); + req.pipe(proxyReq); +}); + app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } })); // Serve code directory files for HTML preview (window.open) @@ -9670,7 +9940,11 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response) try { reqPath = decodeURIComponent(reqPath); } catch {} console.log('[files] reqPath:', reqPath); if (!reqPath) { res.status(400).json({ error: 'No file path provided' }); return; } - if (reqPath.includes('..')) { res.status(403).json({ error: 'Access denied' }); return; } + // Segment-based check — a filename that merely *contains* ".." (e.g. a + // sentence ending in a period right before the extension, "....txt") is not + // a traversal attempt; only an actual ".." path segment is. The isAllowed + // check below (on the resolved absolute path) is the real security boundary. + if (reqPath.split('/').some(seg => seg === '..')) { res.status(403).json({ error: 'Access denied' }); return; } const user = (req as any).user; const globalWorkspace = path.resolve(getConfig().getConfig().workspace?.path || process.cwd()); const workspacePath = user?.workspace || globalWorkspace; @@ -13131,6 +13405,7 @@ app.get('/api/midi-download', async (req: any, res: any) => { registerLanguageRoutes(app); registerDentalRoutes(app); +registerDetectiveDateRoutes(app, getSessionUser); // GET /api/excel/list — list xlsx files from user workspace app.get('/api/excel/list', (req, res) => { diff --git a/web-ui/detective-app.html b/web-ui/detective-app.html index 8459ef1..e975b02 100644 --- a/web-ui/detective-app.html +++ b/web-ui/detective-app.html @@ -115,6 +115,18 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis .dt-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;} .dt-cl-item:hover .dt-cl-del{opacity:.5;} .dt-cl-del:hover{opacity:1 !important;color:#ef4444;} +.dt-ev-group-title{font-size:11px;font-weight:700;color:var(--muted);margin:6px 0 2px;padding-left:2px;} +.dt-ev-photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;} +.dt-ev-photo{position:relative;border-radius:7px;overflow:hidden;border:1px solid var(--line);aspect-ratio:1;background:var(--panel);} +.dt-ev-photo img{width:100%;height:100%;object-fit:cover;display:block;} +.dt-ev-photo .dt-ev-photo-del{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.55);border:none;color:#fff;border-radius:5px;font-size:10px;padding:1px 4px;cursor:pointer;opacity:0;transition:.12s;} +.dt-ev-photo:hover .dt-ev-photo-del{opacity:1;} +.dt-ev-folder-header{display:flex;align-items:center;justify-content:space-between;padding:6px 4px 4px;margin-top:10px;border-top:1px solid var(--line);font-size:12px;font-weight:700;color:var(--text);cursor:pointer;user-select:none;} +.dt-ev-folder-header .dt-cl-del{opacity:.6;font-size:10px;} +.dt-ev-folder-header .dt-cl-del:hover{opacity:1;} +.dt-ev-chevron{display:inline-block;width:11px;font-size:9px;color:var(--muted);} +.dt-ev-folder-body{padding-left:6px;} +.dt-ev-date-title{font-size:10px;font-weight:700;color:var(--muted);margin:6px 0 2px 2px;} .dt-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;} .dt-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;} .dt-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;} @@ -333,6 +345,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis + +
@@ -374,9 +388,41 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
+
+ + +
+
+
+ + + + + + + +
+
+
+
+ + +
+
+
+ + + +
+
+
+
@@ -573,10 +619,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
🔍 탐정 AI + 일반 - +
@@ -588,6 +635,33 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
+ + +