From 9cc359b803e493fbae990cf6c7805ff01270aeb3 Mon Sep 17 00:00:00 2001 From: windpacer Date: Sun, 24 May 2026 06:32:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=ED=83=90=EC=83=89?= =?UTF-8?q?=EA=B8=B0=20(Tab=2016)=20=E2=80=94=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=ED=8F=B4=EB=8D=94=20=ED=8A=B8=EB=A6=AC=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20+=20txt/md/pdf=20=EB=B7=B0=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 폴더 전체를 안전하게 탐색하고 문서를 보고 편집하는 웹 UI. - DocBrowserService: 루트 자동탐색(.git/.sln), 경로이탈·심볼릭·제외디렉토리 ·민감파일 가드, 목록/읽기/원본/쓰기/이름변경/삭제/폴더/업로드 - DocsController(/api/docs): config·tree·text·raw(공개) / 변경계열은 KB admin 토큰 - 뷰어: md(marked+DOMPurify+hljs+KaTeX+mermaid) / pdf(원본 iframe) / txt(pre) / 그 외 다운로드 - 인라인 편집(md 실시간 분할 미리보기) + 관리(새폴더/업로드/이름변경/삭제) - 사이드바 nav 스크롤 수정(.nav overflow), 헤더 컴팩트화로 문서 영역 확보 주의: 프론트 라이브러리(marked/mermaid/katex 등)는 wwwroot/lib/(.gitignore)라 미추적 — 별도 처리 필요. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 53 ++ src/Infrastructure/Docs/DocBrowserService.cs | 350 ++++++++++++ src/Web/Controllers/DocsController.cs | 172 ++++++ src/Web/Program.cs | 1 + src/Web/appsettings.json | 5 + src/Web/wwwroot/css/docs.css | 370 ++++++++++++ src/Web/wwwroot/css/style.css | 3 +- src/Web/wwwroot/index.html | 49 ++ src/Web/wwwroot/js/app.js | 1 + src/Web/wwwroot/js/docs.js | 571 +++++++++++++++++++ 10 files changed, 1574 insertions(+), 1 deletion(-) create mode 100644 src/Infrastructure/Docs/DocBrowserService.cs create mode 100644 src/Web/Controllers/DocsController.cs create mode 100644 src/Web/wwwroot/css/docs.css create mode 100644 src/Web/wwwroot/js/docs.js diff --git a/CLAUDE.md b/CLAUDE.md index 4d1c885..6b19f08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,59 @@ ## 완료된 작업 +### 문서 탐색기 (Tab 16) 구현 (2026-05-24) + +#### 배경 +프로젝트 폴더의 문서를 Web UI에서 직접 보고 관리. 트리는 **프로젝트 폴더 전체**를 탐색, +뷰어는 **txt · md · pdf** 3종(그 외 다운로드), **보기 + 관리 + 인라인 편집** 범위. +사용자 결정: PDF=원본 그대로(iframe), Excel=제외, md 렌더=marked+코드강조+KaTeX+mermaid, +뷰어 바탕=흰색. + +#### 구현 내역 + +| # | 항목 | 핵심 | +|---|------|------| +| 1 | 안전 파일트리 | 루트 자동탐색(.git/*.sln) → 상대경로만 취급, `Path.GetFullPath` 정규화 후 루트이탈·심볼릭이탈 차단. 제외 디렉토리(.git/bin/obj/node_modules/storage…)·민감파일(*.pfx/.env/appsettings*.json…) 숨김+차단 | +| 2 | 뷰어 디스패치 | `md`=marked→DOMPurify→hljs→KaTeX→mermaid / `pdf`=원본 iframe(`Content-Type: application/pdf`) / `txt`=pre / 그 외=다운로드. md 라이브러리는 첫 진입 시 지연로딩 | +| 3 | 인라인 편집 | admin이면 `✎ 편집` → textarea(md는 실시간 분할 미리보기) → `PUT /api/docs/text` 저장 | +| 4 | 관리 | 새 폴더·업로드·이름변경·재귀삭제 — KB admin 토큰(X-Kb-Token) 재사용 | +| 5 | UI | 다크 트리(지연확장·필터·노드별 hover 액션) + **흰색 종이 뷰어**, 상대링크 클릭 시 탐색기 내 이동, 토스트 | + +#### 수정 파일 + +| 파일 | 변경 요약 | +|------|----------| +| `src/Infrastructure/Docs/DocBrowserService.cs` (신규) | 루트결정·`SafeResolve`(이탈/심볼릭/제외/민감 가드)·List/ReadText(바이너리·BOM·크기상한)/OpenRaw(MIME)/WriteText/Rename/Delete/MakeDir/SaveUploadAsync | +| `src/Web/Controllers/DocsController.cs` (신규) | `/api/docs` — config·tree·text·raw(공개) / PUT text·rename·mkdir·upload·DELETE(admin, `IKbAuthService.ValidateAsync`) | +| `src/Web/Program.cs` | `DocBrowserService` 싱글톤 등록 | +| `src/Web/appsettings.json` | `DocBrowser` 섹션(Root="", MaxTextBytes=2MB, MaxUploadBytes=50MB) | +| `src/Web/wwwroot/index.html` | nav-item(16 문서 탐색기), `#pane-docs`(트리+뷰어), docs.css 링크, docs.js 스크립트 | +| `src/Web/wwwroot/js/app.js` | 탭 전환부 `if (tab === 'docs') docsInit()` | +| `src/Web/wwwroot/js/docs.js` (신규) | 트리(지연확장·필터·관리액션 위임), 뷰어 디스패치, md 렌더 파이프라인, 지연 라이브러리 로더, 인라인 에디터, 관리(mkdir/upload/rename/delete), KB 토큰 재사용 잠금해제, 토스트 | +| `src/Web/wwwroot/css/docs.css` (신규) | 다크 트리 + 흰색 종이 뷰어, GitHub-라이트 마크다운 타이포, 편집 분할 | +| `src/Web/wwwroot/lib/` (신규) | marked@12, dompurify@3.1.6, highlight.js@11.10(+github 테마), katex@0.16.11(js/css/auto-render + woff2 20종), mermaid@10.9.1 — 전부 로컬 번들 | + +#### 설계 결정 + +| 항목 | 결정 | +|------|------| +| 적용 환경 | **소스 트리가 있는 개발 환경 전용** (배포본 `/opt/ExperionCrawler`엔 소스 없음). 루트는 `DocBrowser:Root`로 재설정 가능 | +| 인증 | 조회=공개, 변경계열=KB admin 토큰 재사용(별도 비번 없음). 프론트는 `sessionStorage.kbToken` 공유 | +| md 라이브러리 로딩 | 페이지 로드 시점이 아닌 **md 첫 열람 시 지연로딩**(mermaid 3.3MB 등 무게 회피) | +| XSS | marked 출력은 DOMPurify 살균. pdf/원본은 iframe·MIME 한정 | +| download 파라미터 | ASP.NET bool 바인딩은 `true/false`만 허용 — 프론트 `download=true` 사용(초기 `download=1`은 400, 수정함) | +| 뷰어 대상 | 우선 txt/md/pdf만. `DOCS_TEXT_EXT`/`DOCS_MD_EXT` 상수로 추후 확장 용이 | + +#### 빌드/검증 (라이브 :5000) +- `dotnet build` 경고 0 / 에러 0, `node -c docs.js/app.js` OK, 정적자산 11종 200 서빙 +- 루트 자동탐색 → 프로젝트 루트, 트리 `.git/bin/obj` 제외, 경로이탈·민감파일(appsettings/.env)·`.git` 진입 차단 +- md/txt 읽기·하위트리·PDF inline(`application/pdf`)·다운로드 첨부 정상 +- 미인증 PUT/DELETE → 401, admin 로그인 후 mkdir/쓰기/읽기/이름변경/재귀삭제 정상, `.env` 쓰기 차단, 정리 확인 + +#### 잔여 +- 브라우저 실물 렌더(트리 클릭·md/mermaid/KaTeX·편집 UI)는 미확인 — 사용자 `Ctrl+F5` 후 탭 진입으로 확인 필요 +- 검증 위해 기존 `dotnet run`(터미널)을 백그라운드 새 빌드로 교체 기동 + ### Phase 7 + Phase 5 후순위 일괄 구현 (2026-05-14) #### 배경 diff --git a/src/Infrastructure/Docs/DocBrowserService.cs b/src/Infrastructure/Docs/DocBrowserService.cs new file mode 100644 index 0000000..fa23eee --- /dev/null +++ b/src/Infrastructure/Docs/DocBrowserService.cs @@ -0,0 +1,350 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; + +namespace ExperionCrawler.Infrastructure.Docs; + +/// 디렉토리/파일 한 항목. +public sealed record DocEntry(string Name, string RelPath, bool IsDir, long Size, DateTime ModifiedUtc, string Ext); + +/// 텍스트 읽기 결과. +public sealed record DocTextResult(string Text, bool Truncated, long Size, string Ext); + +/// 원본 스트림 + 메타. +public sealed record DocRawResult(Stream Stream, string ContentType, string FileName, string Ext); + +/// 경로 이탈·제외·미존재 등 안전 위반. +public sealed class DocBrowserException : Exception +{ + public DocBrowserException(string message) : base(message) { } +} + +/// +/// 프로젝트 폴더 트리를 안전하게 탐색/편집하기 위한 서비스. +/// 모든 경로는 루트 기준 상대경로로만 다루며, 루트 이탈·제외 디렉토리·민감 파일은 차단한다. +/// 루트는 DocBrowser:Root 설정 또는 (.git/*.sln 자동탐색)으로 결정. 소스 트리가 있는 개발 환경 전용. +/// +public sealed class DocBrowserService +{ + private readonly string _root; + private readonly long _maxTextBytes; + private readonly long _maxUploadBytes; + + // 트리에서 통째로 감출 디렉토리 이름 (대소문자 무시) + private static readonly HashSet ExcludedDirs = new(StringComparer.OrdinalIgnoreCase) + { + ".git", ".hg", ".svn", "bin", "obj", "node_modules", ".vs", ".vscode", ".idea", + ".venv", "venv", "env", "__pycache__", ".pytest_cache", ".mypy_cache", + "dist", "build", "packages", "TestResults", "storage", "data", ".claude", + }; + + // 숨기고 읽기/서빙/편집 모두 차단할 민감 파일 패턴 + private static readonly string[] SensitiveGlobs = + { + "*.pfx", "*.p12", "*.pem", "*.key", "*.crt", "*.cer", + ".env", ".env.*", "appsettings*.json", "secrets.json", "*.user", "*.suo", + "id_rsa", "id_rsa.*", "*.pyc", + }; + + public DocBrowserService(IConfiguration config) + { + _root = ResolveRoot(config["DocBrowser:Root"]); + _maxTextBytes = ParseLong(config["DocBrowser:MaxTextBytes"], 2L * 1024 * 1024); // 2 MB + _maxUploadBytes = ParseLong(config["DocBrowser:MaxUploadBytes"], 50L * 1024 * 1024); // 50 MB + } + + public string Root => _root; + public long MaxTextBytes => _maxTextBytes; + public long MaxUploadBytes => _maxUploadBytes; + + // ── 루트 결정 ─────────────────────────────────────────────── + private static string ResolveRoot(string? configured) + { + if (!string.IsNullOrWhiteSpace(configured)) + { + var p = Path.IsPathRooted(configured) + ? configured + : Path.Combine(Directory.GetCurrentDirectory(), configured); + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(p)); + } + + // 자동: 현재 디렉토리에서 위로 올라가며 .git 또는 *.sln 보유 디렉토리를 루트로 + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || dir.GetFiles("*.sln").Length > 0) + return Path.TrimEndingDirectorySeparator(dir.FullName); + dir = dir.Parent; + } + return Path.TrimEndingDirectorySeparator(Directory.GetCurrentDirectory()); + } + + private static long ParseLong(string? s, long fallback) + => long.TryParse(s, out var v) && v > 0 ? v : fallback; + + // ── 경로 안전 ─────────────────────────────────────────────── + + /// 상대경로를 절대경로로 변환. 루트 이탈·제외 디렉토리·민감 파일이면 예외. + public string SafeResolve(string? relPath, bool mustExist = false) + { + var rel = (relPath ?? "").Replace('\\', '/').Trim(); + if (rel.Contains('\0')) throw new DocBrowserException("잘못된 경로"); + rel = rel.TrimStart('/'); + if (rel is "." or "./") rel = ""; + + var candidate = Path.GetFullPath(Path.Combine(_root, rel)); + candidate = Path.TrimEndingDirectorySeparator(candidate); + + if (!IsWithinRoot(candidate)) + throw new DocBrowserException("루트 밖 경로 접근 차단"); + + // 심볼릭 링크가 루트 밖을 가리키면 차단 (존재할 때만 검사) + var real = ResolveFinalTarget(candidate); + if (real != null && !IsWithinRoot(real)) + throw new DocBrowserException("루트 밖 링크 접근 차단"); + + // 루트~대상 사이 모든 세그먼트 검사 + var relToRoot = Path.GetRelativePath(_root, candidate); + if (relToRoot != ".") + { + var segs = relToRoot.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < segs.Length; i++) + { + var seg = segs[i]; + bool isLast = i == segs.Length - 1; + // 마지막이 아니면 반드시 디렉토리 → 디렉토리 제외 검사 + if (!isLast && ExcludedDirs.Contains(seg)) + throw new DocBrowserException($"제외된 디렉토리: {seg}"); + // 마지막 세그먼트: 디렉토리면 dir-제외, 파일이면 민감 검사 + if (isLast) + { + if (ExcludedDirs.Contains(seg) && Directory.Exists(candidate)) + throw new DocBrowserException($"제외된 디렉토리: {seg}"); + if (IsSensitiveFile(seg)) + throw new DocBrowserException("민감 파일 접근 차단"); + } + } + } + + if (mustExist && !File.Exists(candidate) && !Directory.Exists(candidate)) + throw new DocBrowserException("대상이 존재하지 않습니다"); + + return candidate; + } + + private bool IsWithinRoot(string absPath) + { + if (string.Equals(absPath, _root, PathComparison)) return true; + return absPath.StartsWith(_root + Path.DirectorySeparatorChar, PathComparison); + } + + private static string? ResolveFinalTarget(string path) + { + try + { + FileSystemInfo? info = Directory.Exists(path) ? new DirectoryInfo(path) + : File.Exists(path) ? new FileInfo(path) : null; + if (info == null) return null; + var target = info.ResolveLinkTarget(returnFinalTarget: true); + return target == null ? null : Path.TrimEndingDirectorySeparator(target.FullName); + } + catch { return null; } + } + + private static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + public bool IsExcludedDir(string name) => ExcludedDirs.Contains(name); + + public bool IsSensitiveFile(string name) + { + foreach (var g in SensitiveGlobs) + if (WildcardMatch(name, g)) return true; + return false; + } + + private static bool WildcardMatch(string name, string pattern) + { + var rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$"; + return Regex.IsMatch(name, rx, RegexOptions.IgnoreCase); + } + + /// 절대경로 → 루트 기준 상대경로(슬래시). 루트 자신은 "". + public string ToRel(string absPath) + { + var rel = Path.GetRelativePath(_root, absPath); + return rel == "." ? "" : rel.Replace('\\', '/'); + } + + // ── 목록 ──────────────────────────────────────────────────── + + public List List(string? relPath) + { + var abs = SafeResolve(relPath, mustExist: true); + if (!Directory.Exists(abs)) throw new DocBrowserException("디렉토리가 아닙니다"); + + var result = new List(); + var di = new DirectoryInfo(abs); + + foreach (var sub in di.EnumerateDirectories()) + { + if (ExcludedDirs.Contains(sub.Name)) continue; + if ((sub.Attributes & FileAttributes.System) != 0) continue; + result.Add(new DocEntry(sub.Name, ToRel(sub.FullName), true, 0, sub.LastWriteTimeUtc, "")); + } + foreach (var f in di.EnumerateFiles()) + { + if (IsSensitiveFile(f.Name)) continue; + if ((f.Attributes & FileAttributes.System) != 0) continue; + result.Add(new DocEntry(f.Name, ToRel(f.FullName), false, f.Length, + f.LastWriteTimeUtc, f.Extension.TrimStart('.').ToLowerInvariant())); + } + + return result + .OrderByDescending(e => e.IsDir) + .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + // ── 텍스트 읽기 ───────────────────────────────────────────── + + public DocTextResult ReadText(string? relPath) + { + var abs = SafeResolve(relPath, mustExist: true); + if (!File.Exists(abs)) throw new DocBrowserException("파일이 아닙니다"); + + var fi = new FileInfo(abs); + var cap = (int)Math.Min(fi.Length, _maxTextBytes); + var buf = new byte[cap]; + int total = 0; + using (var fs = fi.OpenRead()) + { + int r; + while (total < cap && (r = fs.Read(buf, total, cap - total)) > 0) total += r; + } + + // 바이너리 휴리스틱: 앞부분에 NUL 이 있으면 텍스트 아님 + int scan = Math.Min(total, 8000); + for (int i = 0; i < scan; i++) + if (buf[i] == 0) throw new DocBrowserException("바이너리 파일은 텍스트로 볼 수 없습니다"); + + var text = DecodeUtf8(buf, total); + return new DocTextResult(text, fi.Length > _maxTextBytes, fi.Length, + fi.Extension.TrimStart('.').ToLowerInvariant()); + } + + private static string DecodeUtf8(byte[] buf, int len) + { + // UTF-8 BOM 제거 + int start = (len >= 3 && buf[0] == 0xEF && buf[1] == 0xBB && buf[2] == 0xBF) ? 3 : 0; + return new UTF8Encoding(false, false).GetString(buf, start, len - start); + } + + // ── 원본 스트림 ───────────────────────────────────────────── + + public DocRawResult OpenRaw(string? relPath) + { + var abs = SafeResolve(relPath, mustExist: true); + if (!File.Exists(abs)) throw new DocBrowserException("파일이 아닙니다"); + var ext = Path.GetExtension(abs).TrimStart('.').ToLowerInvariant(); + var stream = new FileStream(abs, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, true); + return new DocRawResult(stream, ContentType(ext), Path.GetFileName(abs), ext); + } + + public static string ContentType(string ext) => ext switch + { + "pdf" => "application/pdf", + "txt" or "log" or "md" or "markdown" or "csv" or "ini" or "conf" or "cfg" => "text/plain; charset=utf-8", + "json" => "application/json; charset=utf-8", + "xml" => "application/xml; charset=utf-8", + "html" or "htm" => "text/html; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" or "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + _ => "application/octet-stream", + }; + + // ── 변경 작업 (admin) ────────────────────────────────────── + + public void WriteText(string? relPath, string content) + { + var abs = SafeResolve(relPath); + if (Directory.Exists(abs)) throw new DocBrowserException("디렉토리에는 쓸 수 없습니다"); + var bytes = new UTF8Encoding(false).GetBytes(content ?? ""); + if (bytes.LongLength > _maxTextBytes) throw new DocBrowserException("저장 한도를 초과했습니다"); + var parent = Path.GetDirectoryName(abs); + if (parent == null || !Directory.Exists(parent)) throw new DocBrowserException("상위 폴더가 없습니다"); + File.WriteAllBytes(abs, bytes); + } + + public string Rename(string? fromRel, string? toRel) + { + var from = SafeResolve(fromRel, mustExist: true); + var to = SafeResolve(toRel); + if (File.Exists(to) || Directory.Exists(to)) throw new DocBrowserException("대상이 이미 존재합니다"); + var parent = Path.GetDirectoryName(to); + if (parent == null || !Directory.Exists(parent)) throw new DocBrowserException("대상 상위 폴더가 없습니다"); + + if (Directory.Exists(from)) Directory.Move(from, to); + else if (File.Exists(from)) File.Move(from, to); + else throw new DocBrowserException("원본이 없습니다"); + return ToRel(to); + } + + public void Delete(string? relPath, bool recursive) + { + var abs = SafeResolve(relPath, mustExist: true); + if (string.Equals(abs, _root, PathComparison)) throw new DocBrowserException("루트는 삭제할 수 없습니다"); + + if (Directory.Exists(abs)) + { + if (!recursive && Directory.EnumerateFileSystemEntries(abs).Any()) + throw new DocBrowserException("폴더가 비어있지 않습니다 (recursive 필요)"); + Directory.Delete(abs, recursive); + } + else if (File.Exists(abs)) + { + File.Delete(abs); + } + } + + public string MakeDir(string? relPath) + { + var abs = SafeResolve(relPath); + if (File.Exists(abs)) throw new DocBrowserException("같은 이름의 파일이 있습니다"); + Directory.CreateDirectory(abs); + return ToRel(abs); + } + + public async Task SaveUploadAsync(string? dirRel, string fileName, Stream input, CancellationToken ct) + { + var safeName = Path.GetFileName(fileName ?? ""); + if (string.IsNullOrWhiteSpace(safeName)) throw new DocBrowserException("파일명이 필요합니다"); + if (IsSensitiveFile(safeName)) throw new DocBrowserException("이 형식은 업로드할 수 없습니다"); + + var dirAbs = SafeResolve(dirRel, mustExist: true); + if (!Directory.Exists(dirAbs)) throw new DocBrowserException("대상 폴더가 없습니다"); + + var abs = SafeResolve(Path.Combine(ToRel(dirAbs), safeName)); + if (File.Exists(abs)) throw new DocBrowserException("같은 이름의 파일이 이미 있습니다"); + + using var fs = new FileStream(abs, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, true); + var buffer = new byte[64 * 1024]; + long total = 0; + int read; + while ((read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0) + { + total += read; + if (total > _maxUploadBytes) + { + fs.Close(); + try { File.Delete(abs); } catch { } + throw new DocBrowserException("업로드 한도를 초과했습니다"); + } + await fs.WriteAsync(buffer.AsMemory(0, read), ct); + } + return ToRel(abs); + } +} diff --git a/src/Web/Controllers/DocsController.cs b/src/Web/Controllers/DocsController.cs new file mode 100644 index 0000000..84f3317 --- /dev/null +++ b/src/Web/Controllers/DocsController.cs @@ -0,0 +1,172 @@ +using ExperionCrawler.Infrastructure.Docs; +using ExperionCrawler.Infrastructure.Kb; +using Microsoft.AspNetCore.Mvc; + +namespace ExperionCrawler.Web.Controllers; + +/// +/// 프로젝트 폴더 문서 탐색기 API. 조회는 공개, 변경 작업(편집/이름변경/삭제/폴더/업로드)은 +/// KB admin 토큰(X-Kb-Token) 필요. 경로 안전·제외 규칙은 DocBrowserService 가 강제한다. +/// +[ApiController] +[Route("api/docs")] +public class DocsController : ControllerBase +{ + private readonly DocBrowserService _docs; + private readonly IKbAuthService _auth; + private readonly ILogger _logger; + + public DocsController(DocBrowserService docs, IKbAuthService auth, ILogger logger) + { + _docs = docs; + _auth = auth; + _logger = logger; + } + + private Task IsAdminAsync(CancellationToken ct) + => _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct); + + private IActionResult Fail(string error) => Ok(new { success = false, error }); + + // ── 메타/설정 ─────────────────────────────────────────────── + [HttpGet("config")] + public async Task Config(CancellationToken ct) + => Ok(new + { + success = true, + root = _docs.Root, + canManage = await IsAdminAsync(ct), + maxTextBytes = _docs.MaxTextBytes, + maxUploadBytes = _docs.MaxUploadBytes, + }); + + // ── 디렉토리 목록 ─────────────────────────────────────────── + [HttpGet("tree")] + public IActionResult Tree([FromQuery] string? path) + { + try + { + var entries = _docs.List(path).Select(e => new + { + name = e.Name, + path = e.RelPath, + type = e.IsDir ? "dir" : "file", + size = e.Size, + mtime = e.ModifiedUtc, + ext = e.Ext, + }); + return Ok(new { success = true, path = path ?? "", entries }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } + + // ── 텍스트 읽기 ───────────────────────────────────────────── + [HttpGet("text")] + public IActionResult Text([FromQuery] string path) + { + try + { + var r = _docs.ReadText(path); + return Ok(new { success = true, text = r.Text, truncated = r.Truncated, size = r.Size, ext = r.Ext }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } + + // ── 원본 스트림 (pdf iframe / 다운로드) ───────────────────── + [HttpGet("raw")] + public IActionResult Raw([FromQuery] string path, [FromQuery] bool download = false) + { + try + { + var r = _docs.OpenRaw(path); + if (download) + return File(r.Stream, "application/octet-stream", r.FileName); + return File(r.Stream, r.ContentType); // inline (pdf 등) + } + catch (DocBrowserException ex) { return NotFound(new { success = false, error = ex.Message }); } + } + + // ── 텍스트 저장 (admin) ───────────────────────────────────── + public sealed record WriteRequest(string Path, string Content); + + [HttpPut("text")] + [RequestSizeLimit(8_000_000)] + public async Task WriteText([FromBody] WriteRequest req, CancellationToken ct) + { + if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" }); + if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required"); + try + { + _docs.WriteText(req.Path, req.Content ?? ""); + _logger.LogInformation("[Docs] 저장 {Path}", req.Path); + return Ok(new { success = true }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } + + // ── 이름변경/이동 (admin) ─────────────────────────────────── + public sealed record RenameRequest(string From, string To); + + [HttpPost("rename")] + public async Task Rename([FromBody] RenameRequest req, CancellationToken ct) + { + if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" }); + if (req == null || string.IsNullOrEmpty(req.From) || string.IsNullOrEmpty(req.To)) + return Fail("from/to required"); + try + { + var newRel = _docs.Rename(req.From, req.To); + _logger.LogInformation("[Docs] 이름변경 {From} → {To}", req.From, newRel); + return Ok(new { success = true, path = newRel }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } + + // ── 새 폴더 (admin) ───────────────────────────────────────── + public sealed record MkdirRequest(string Path); + + [HttpPost("mkdir")] + public async Task Mkdir([FromBody] MkdirRequest req, CancellationToken ct) + { + if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" }); + if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required"); + try + { + var rel = _docs.MakeDir(req.Path); + return Ok(new { success = true, path = rel }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } + + // ── 삭제 (admin) ──────────────────────────────────────────── + [HttpDelete] + public async Task Delete([FromQuery] string path, [FromQuery] bool recursive, CancellationToken ct) + { + if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" }); + if (string.IsNullOrEmpty(path)) return Fail("path required"); + try + { + _docs.Delete(path, recursive); + _logger.LogInformation("[Docs] 삭제 {Path} (recursive={R})", path, recursive); + return Ok(new { success = true }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } + + // ── 업로드 (admin) ────────────────────────────────────────── + [HttpPost("upload")] + [RequestSizeLimit(100_000_000)] + public async Task Upload([FromForm] IFormFile file, [FromForm] string? path, CancellationToken ct) + { + if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" }); + if (file == null || file.Length == 0) return Fail("file required"); + try + { + await using var stream = file.OpenReadStream(); + var rel = await _docs.SaveUploadAsync(path, file.FileName, stream, ct); + _logger.LogInformation("[Docs] 업로드 {Path} ({Size} bytes)", rel, file.Length); + return Ok(new { success = true, path = rel }); + } + catch (DocBrowserException ex) { return Fail(ex.Message); } + } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 46ea6c5..274a260 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -140,6 +140,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index 8b45678..a57c12f 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -51,6 +51,11 @@ "WorkingDirectory": "../../mcp-server" }, "PromptsDirectory": "../../prompts", + "DocBrowser": { + "Root": "", + "MaxTextBytes": 2097152, + "MaxUploadBytes": 52428800 + }, "Kb": { "QdrantUrl": "http://localhost:6333", "VectorSize": 768, diff --git a/src/Web/wwwroot/css/docs.css b/src/Web/wwwroot/css/docs.css new file mode 100644 index 0000000..dd0e9cd --- /dev/null +++ b/src/Web/wwwroot/css/docs.css @@ -0,0 +1,370 @@ +/* ════════════════════════════════════════════════════════════ + 문서 탐색기 (Tab 16) — 다크 트리 + 흰색 종이 뷰어 + ════════════════════════════════════════════════════════════ */ + +/* 헤더 컴팩트화: 제목 위로 + 부제목을 제목 옆으로 → 문서 영역 확보 */ +#pane-docs .pane-hdr { margin-bottom: 10px; } +#pane-docs .pane-hdr > div:first-child { + display: flex; + align-items: baseline; + gap: 14px; + flex-wrap: wrap; +} +#pane-docs .pane-hdr h1 { margin-bottom: 0; } +#pane-docs .pane-hdr p { margin-bottom: 0; } + +.docs-layout { + display: flex; + gap: 0; + height: calc(100vh - 116px); + min-height: 560px; + border: 1px solid var(--bd); + border-radius: var(--r); + overflow: hidden; + background: var(--s1); +} + +/* ── 좌: 파일 트리 패널 (다크) ─────────────────────────────── */ +.docs-tree-panel { + width: 300px; + flex-shrink: 0; + background: var(--s2); + border-right: 1px solid var(--bd); + display: flex; + flex-direction: column; + min-height: 0; +} + +.docs-tree-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--bd); +} +.docs-tree-toolbar .docs-filter { + flex: 1; + min-width: 0; + height: 30px; + font-size: 12px; + padding: 4px 8px; +} + +.docs-root-line { + padding: 5px 10px; + font-size: 10px; + color: var(--t2); + border-bottom: 1px solid var(--bd); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + direction: rtl; /* 긴 경로는 앞쪽을 ... 으로 */ + text-align: left; +} + +.docs-tree { + flex: 1; + overflow: auto; + padding: 6px 4px; + min-height: 0; +} + +.docs-admin-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--bd); +} +.docs-admin-state { font-size: 11px; color: var(--t1); } + +/* ── 트리 노드 ─────────────────────────────────────────────── */ +.docs-ul, .docs-children { list-style: none; margin: 0; padding: 0; } +.docs-li { margin: 0; } + +.docs-node { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 5px; + cursor: pointer; + color: var(--t1); + font-size: 12.5px; + user-select: none; + white-space: nowrap; +} +.docs-node:hover { background: var(--s4); color: var(--t0); } +.docs-node.active { background: var(--ag); color: var(--blu); } + +.docs-node-ic { + flex-shrink: 0; + width: 16px; + text-align: center; + font-size: 11px; + transition: transform .15s ease; +} +.docs-node.is-dir > .docs-node-ic { color: var(--t2); } +.docs-node.is-dir.open > .docs-node-ic { transform: rotate(90deg); } + +.docs-node-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} +.docs-node-size { + flex-shrink: 0; + font-family: var(--fm); + font-size: 9.5px; + color: var(--t2); +} + +.docs-node-act { display: none; flex-shrink: 0; gap: 2px; } +.docs-tree.can-manage .docs-node:hover .docs-node-act { display: flex; } +.docs-node-act button { + background: transparent; + border: none; + color: var(--t2); + cursor: pointer; + font-size: 11px; + padding: 0 3px; + border-radius: 4px; +} +.docs-node-act button:hover { color: var(--t0); background: rgba(255,255,255,.08); } +.docs-act-delete:hover { color: var(--red) !important; } + +.docs-loading, .docs-error { padding: 8px 12px; font-size: 12px; color: var(--t2); } +.docs-error { color: var(--red); white-space: pre-wrap; } + +/* ── 우: 뷰어 패널 ─────────────────────────────────────────── */ +.docs-viewer-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.docs-viewer-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 14px; + background: var(--s2); + border-bottom: 1px solid var(--bd); +} +.docs-cur-path { + font-size: 11.5px; + color: var(--t1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; +} +.docs-viewer-actions { display: flex; gap: 6px; flex-shrink: 0; } +.docs-viewer-actions a.btn-b, .docs-viewer-actions a.btn-a { text-decoration: none; } + +/* ── 뷰어 본문: 흰색 종이 ──────────────────────────────────── */ +.docs-viewer { + flex: 1; + overflow: auto; + background: #ffffff; + color: #24292f; + min-height: 0; +} +.docs-viewer.mode-pdf { overflow: hidden; } + +.docs-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: #8b949e; + font-size: 14px; +} + +.docs-trunc { + background: #fff8e6; + color: #7a5b00; + border-bottom: 1px solid #f0d98c; + padding: 8px 16px; + font-size: 12px; +} + +/* 텍스트 뷰어 */ +.docs-text-pre { + margin: 0; + padding: 20px 24px; + font-family: var(--fm); + font-size: 13px; + line-height: 1.65; + color: #24292f; + white-space: pre-wrap; + word-break: break-word; +} + +/* PDF 뷰어 */ +.docs-pdf-frame { width: 100%; height: 100%; border: 0; background: #fff; } + +/* 미지원 */ +.docs-unsupported { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: #57606a; + text-align: center; +} +.docs-unsupported-ic { font-size: 48px; } +.docs-unsupported .docs-muted { color: #8b949e; font-size: 12px; } +.docs-unsupported a { margin-top: 8px; text-decoration: none; } + +/* ── 마크다운 본문 (GitHub-라이트 타이포) ──────────────────── */ +.md-body { + padding: 28px 36px; + max-width: 960px; + margin: 0 auto; + color: #24292f; + font-size: 15px; + line-height: 1.7; + word-wrap: break-word; +} +.md-body > *:first-child { margin-top: 0; } +.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 { + margin: 26px 0 14px; + font-weight: 700; + line-height: 1.3; + color: #1f2328; +} +.md-body h1 { font-size: 1.9em; padding-bottom: .3em; border-bottom: 1px solid #d8dee4; } +.md-body h2 { font-size: 1.5em; padding-bottom: .3em; border-bottom: 1px solid #d8dee4; } +.md-body h3 { font-size: 1.25em; } +.md-body h4 { font-size: 1.05em; } +.md-body p { margin: 0 0 14px; } +.md-body a { color: #0969da; text-decoration: none; } +.md-body a:hover { text-decoration: underline; } +.md-body ul, .md-body ol { margin: 0 0 14px; padding-left: 2em; } +.md-body li { margin: 3px 0; } +.md-body li > p { margin: 4px 0; } +.md-body blockquote { + margin: 0 0 14px; + padding: 2px 16px; + color: #57606a; + border-left: 4px solid #d0d7de; +} +.md-body hr { height: 1px; border: 0; background: #d8dee4; margin: 24px 0; } +.md-body img { max-width: 100%; } + +/* 인라인 코드 */ +.md-body code { + font-family: var(--fm); + font-size: 85%; + background: rgba(175,184,193,.2); + padding: .2em .4em; + border-radius: 6px; +} +/* 코드블록 (highlight.js github 테마가 색 담당) */ +.md-body pre { + margin: 0 0 16px; + padding: 14px 16px; + background: #f6f8fa; + border: 1px solid #e4e8ec; + border-radius: 8px; + overflow: auto; + line-height: 1.5; +} +.md-body pre code { + background: transparent; + padding: 0; + font-size: 13px; + border-radius: 0; +} + +/* GFM 표 */ +.md-body table { + border-collapse: collapse; + margin: 0 0 16px; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} +.md-body th, .md-body td { + border: 1px solid #d0d7de; + padding: 6px 13px; +} +.md-body th { background: #f6f8fa; font-weight: 700; } +.md-body tr:nth-child(2n) td { background: #f6f8fa; } + +/* 체크박스 리스트 */ +.md-body input[type="checkbox"] { margin-right: 6px; } + +/* mermaid */ +.md-body .mermaid { margin: 0 0 16px; text-align: center; } +.md-body .mermaid svg { max-width: 100%; height: auto; } + +/* ── 편집 모드 ─────────────────────────────────────────────── */ +.docs-viewer.mode-edit { background: #ffffff; } +.docs-edit-wrap { + display: flex; + height: 100%; + min-height: 0; +} +.docs-edit-ta { + flex: 1; + border: 0; + outline: none; + resize: none; + padding: 20px 24px; + font-family: var(--fm); + font-size: 13px; + line-height: 1.65; + color: #24292f; + background: #ffffff; + min-width: 0; +} +.docs-viewer.mode-edit.is-md .docs-edit-ta { + border-right: 1px solid #d8dee4; + flex: 0 0 50%; +} +.docs-edit-prev { + flex: 1; + overflow: auto; + padding: 20px 28px; + max-width: none; + margin: 0; + background: #fbfcfd; + min-width: 0; +} + +/* ── 토스트 ────────────────────────────────────────────────── */ +.docs-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--s3); + color: var(--t0); + border: 1px solid var(--bd2); + padding: 10px 18px; + border-radius: 8px; + font-size: 13px; + box-shadow: 0 8px 24px rgba(0,0,0,.4); + opacity: 0; + pointer-events: none; + transition: opacity .2s ease, transform .2s ease; + z-index: 1000; +} +.docs-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* 좁은 화면 */ +@media (max-width: 900px) { + .docs-tree-panel { width: 200px; } + .docs-viewer.mode-edit.is-md .docs-edit-ta { flex: 0 0 100%; } + .docs-edit-prev { display: none; } +} diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css index 6cfd0df..d70a517 100644 --- a/src/Web/wwwroot/css/style.css +++ b/src/Web/wwwroot/css/style.css @@ -68,10 +68,11 @@ html, body { height: 100%; background: var(--s0); color: var(--t1); font-family: } /* nav */ -.nav { list-style: none; padding: 14px 10px; flex: 1; display: flex; flex-direction: column; gap: 3px; } +.nav { list-style: none; padding: 14px 10px; flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; } .nav-item { display: flex; align-items: center; gap: 10px; + flex-shrink: 0; padding: 10px 12px; border-radius: var(--r); cursor: pointer; transition: all var(--tr); color: var(--t2); font-size: 13px; font-weight: 600; diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html index d240288..33f9d8d 100644 --- a/src/Web/wwwroot/index.html +++ b/src/Web/wwwroot/index.html @@ -6,6 +6,7 @@ ExperionCrawler +
@@ -92,6 +93,10 @@ 15 OPC UA Write +
@@ -1623,6 +1628,49 @@ + +
+
+
+

문서 탐색기

+

프로젝트 폴더의 문서를 직접 보고 편집합니다. 뷰어: txt · md · pdf · 그 외 다운로드.

+
+
DOCS
+
+ +
+ + + + +
+
+ 파일을 선택하세요 +
+
+
+
← 왼쪽에서 문서를 선택하세요
+
+
+
+
+
@@ -1708,5 +1756,6 @@ + diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js index 1838c85..6837422 100644 --- a/src/Web/wwwroot/js/app.js +++ b/src/Web/wwwroot/js/app.js @@ -15,6 +15,7 @@ document.querySelectorAll('.nav-item').forEach(item => { if (tab === 'opcsvr') srvLoad(); if (tab === 't2s') t2sInitMode(); if (tab === 'fast') fastSessionsLoad(); + if (tab === 'docs') docsInit(); }); }); diff --git a/src/Web/wwwroot/js/docs.js b/src/Web/wwwroot/js/docs.js new file mode 100644 index 0000000..b4561a9 --- /dev/null +++ b/src/Web/wwwroot/js/docs.js @@ -0,0 +1,571 @@ +/* ════════════════════════════════════════════════════════════════ + 문서 탐색기 (Tab 16) — 프로젝트 폴더 트리 탐색 + txt/md/pdf 뷰어 + 인라인 편집/관리 + 백엔드: /api/docs/* (변경 작업은 X-Kb-Token admin) + esc() / kbToken 은 app.js(선로딩) 전역 재사용 + ════════════════════════════════════════════════════════════════ */ + +const DOCS_TEXT_EXT = ['txt', 'log', 'text']; +const DOCS_MD_EXT = ['md', 'markdown']; + +const docsState = { + inited: false, + canManage: false, + root: '', + curFile: null, // { path, ext } + editing: false, +}; + +/* ── 진입 ──────────────────────────────────────────────────── */ +async function docsInit() { + await docsLoadConfig(); + if (!docsState.inited) { + docsState.inited = true; + document.getElementById('docs-tree').addEventListener('click', docsTreeClick); + await docsRefresh(); + } +} + +async function docsLoadConfig() { + try { + const r = await fetch('/api/docs/config', { headers: docsHeaders() }); + const d = await r.json(); + docsState.canManage = !!d.canManage; + docsState.root = d.root || ''; + document.getElementById('docs-root-label').textContent = d.root || ''; + docsUpdateAdminUi(); + } catch (e) { /* noop */ } +} + +/* ── 인증 헤더 (KB admin 토큰 재사용) ───────────────────────── */ +function docsToken() { return sessionStorage.getItem('kbToken') || ''; } +function docsHeaders(extra) { + const h = extra || {}; + const t = docsToken(); + if (t) h['X-Kb-Token'] = t; + return h; +} + +/* ── 관리 UI 토글 ──────────────────────────────────────────── */ +function docsUpdateAdminUi() { + const on = docsState.canManage; + document.getElementById('docs-mkdir-btn').hidden = !on; + document.getElementById('docs-upload-btn').hidden = !on; + document.getElementById('docs-tree').classList.toggle('can-manage', on); + document.getElementById('docs-admin-state').textContent = on ? '🔓 관리 가능' : '🔒 읽기 전용'; + document.getElementById('docs-unlock-btn').textContent = on ? '잠금' : '관리 잠금해제'; + if (docsState.curFile) docsRenderViewerActions(); +} + +async function docsUnlock() { + if (docsState.canManage) { + try { await fetch('/api/kb/auth/logout', { method: 'POST', headers: docsHeaders() }); } catch (e) {} + sessionStorage.removeItem('kbToken'); + try { kbToken = ''; } catch (e) {} + await docsLoadConfig(); + return; + } + const pw = prompt('관리자 비밀번호'); + if (!pw) return; + try { + const r = await fetch('/api/kb/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }), + }); + const d = await r.json(); + if (!d.success) { alert('로그인 실패: ' + (d.error || '')); return; } + sessionStorage.setItem('kbToken', d.token); + try { kbToken = d.token; } catch (e) {} + await docsLoadConfig(); + docsToast('관리 잠금해제됨'); + } catch (e) { alert('로그인 오류: ' + e.message); } +} + +/* ── 트리 ──────────────────────────────────────────────────── */ +async function docsFetchTree(path) { + const r = await fetch('/api/docs/tree?path=' + encodeURIComponent(path || ''), { headers: docsHeaders() }); + const d = await r.json(); + if (!d.success) throw new Error(d.error || '목록 조회 실패'); + return d.entries; +} + +async function docsRefresh() { + const host = document.getElementById('docs-tree'); + host.innerHTML = '
불러오는 중…
'; + try { + const entries = await docsFetchTree(''); + const ul = document.createElement('ul'); + ul.className = 'docs-ul'; + docsRenderInto(ul, entries, 0); + host.innerHTML = ''; + host.appendChild(ul); + docsApplyFilter(); + } catch (e) { + host.innerHTML = '
' + esc(e.message) + '
'; + } +} + +function docsRenderInto(ul, entries, depth) { + for (const e of entries) { + const li = document.createElement('li'); + li.className = 'docs-li'; + li.appendChild(docsNodeRow(e, depth)); + if (e.type === 'dir') { + const kids = document.createElement('ul'); + kids.className = 'docs-children'; + kids.hidden = true; + li.appendChild(kids); + } + ul.appendChild(li); + } +} + +function docsNodeRow(entry, depth) { + const row = document.createElement('div'); + row.className = 'docs-node ' + (entry.type === 'dir' ? 'is-dir' : 'is-file'); + row.style.paddingLeft = (8 + depth * 14) + 'px'; + row.dataset.path = entry.path; + row.dataset.type = entry.type; + row.dataset.name = (entry.name || '').toLowerCase(); + row.dataset.ext = entry.ext || ''; + row.dataset.depth = depth; + + const ic = entry.type === 'dir' ? '▸' : docsFileIcon(entry.ext); + const size = entry.type === 'file' ? `${docsFmtSize(entry.size)}` : ''; + const acts = ` + + + `; + row.innerHTML = + `${ic}` + + `${esc(entry.name)}` + + size + acts; + return row; +} + +async function docsTreeClick(ev) { + const renameBtn = ev.target.closest('.docs-act-rename'); + const deleteBtn = ev.target.closest('.docs-act-delete'); + const row = ev.target.closest('.docs-node'); + if (!row) return; + + if (renameBtn) { ev.stopPropagation(); return docsRenamePrompt(row.dataset.path); } + if (deleteBtn) { ev.stopPropagation(); return docsDeletePrompt(row.dataset.path, row.dataset.type); } + + if (row.dataset.type === 'dir') return docsToggleDir(row); + // 파일 + document.querySelectorAll('#docs-tree .docs-node.active').forEach(n => n.classList.remove('active')); + row.classList.add('active'); + docsOpenPath(row.dataset.path); +} + +async function docsToggleDir(row) { + const li = row.parentElement; + const kids = li.querySelector(':scope > .docs-children'); + if (!kids) return; + if (kids.hidden) { + if (kids.dataset.loaded !== '1') { + kids.innerHTML = '
  • '; + try { + const entries = await docsFetchTree(row.dataset.path); + kids.innerHTML = ''; + docsRenderInto(kids, entries, (+row.dataset.depth) + 1); + kids.dataset.loaded = '1'; + docsApplyFilter(); + } catch (e) { + kids.innerHTML = '
  • ' + esc(e.message) + '
  • '; + } + } + kids.hidden = false; + row.classList.add('open'); + } else { + kids.hidden = true; + row.classList.remove('open'); + } +} + +/* ── 파일 열기 / 뷰어 디스패치 ─────────────────────────────── */ +function docsOpenPath(path) { + const ext = (path.split('.').pop() || '').toLowerCase(); + docsState.curFile = { path, ext }; + docsState.editing = false; + document.getElementById('docs-cur-path').textContent = path; + docsRenderViewerActions(); + if (DOCS_MD_EXT.includes(ext)) docsViewMarkdown(path); + else if (ext === 'pdf') docsViewPdf(path); + else if (DOCS_TEXT_EXT.includes(ext)) docsViewText(path); + else docsViewUnsupported(path, ext); +} + +async function docsFetchText(path) { + const r = await fetch('/api/docs/text?path=' + encodeURIComponent(path), { headers: docsHeaders() }); + const d = await r.json(); + if (!d.success) throw new Error(d.error || '파일 읽기 실패'); + return d; +} + +function docsViewer() { return document.getElementById('docs-viewer'); } +function docsViewerError(err) { + docsViewer().className = 'docs-viewer'; + docsViewer().innerHTML = '
    ' + esc(err.message || String(err)) + '
    '; +} +function docsTruncBanner(size) { + const b = document.createElement('div'); + b.className = 'docs-trunc'; + b.textContent = `⚠ 파일이 커서 일부만 표시합니다 (전체 ${docsFmtSize(size)}). 다운로드로 전체 확인하세요.`; + return b; +} + +async function docsViewText(path) { + const v = docsViewer(); + v.className = 'docs-viewer mode-text'; + v.innerHTML = '
    불러오는 중…
    '; + try { + const d = await docsFetchText(path); + v.innerHTML = ''; + if (d.truncated) v.appendChild(docsTruncBanner(d.size)); + const pre = document.createElement('pre'); + pre.className = 'docs-text-pre'; + pre.textContent = d.text; + v.appendChild(pre); + } catch (e) { docsViewerError(e); } +} + +function docsViewPdf(path) { + const v = docsViewer(); + v.className = 'docs-viewer mode-pdf'; + const src = '/api/docs/raw?path=' + encodeURIComponent(path); + v.innerHTML = ``; +} + +async function docsViewMarkdown(path) { + const v = docsViewer(); + v.className = 'docs-viewer mode-md'; + v.innerHTML = '
    렌더링 준비 중…
    '; + try { + await docsEnsureMdLibs(); + const d = await docsFetchText(path); + v.innerHTML = ''; + if (d.truncated) v.appendChild(docsTruncBanner(d.size)); + const body = document.createElement('div'); + body.className = 'md-body'; + v.appendChild(body); + docsRenderMarkdownInto(body, d.text); + } catch (e) { docsViewerError(e); } +} + +function docsViewUnsupported(path, ext) { + const v = docsViewer(); + v.className = 'docs-viewer mode-unsupported'; + v.innerHTML = `
    +
    ${docsFileIcon(ext)}
    +

    .${esc(ext || '?')} 형식은 미리보기를 지원하지 않습니다.

    +

    지원 뷰어: txt · md · pdf

    + ⬇ 다운로드 +
    `; +} + +/* ── 마크다운 렌더 파이프라인 ──────────────────────────────── */ +function docsRenderMarkdownInto(el, text) { + const rawHtml = marked.parse(text, { gfm: true, breaks: false }); + el.innerHTML = DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['target'] }); + docsExternalLinks(el); + docsExtractMermaid(el); // mermaid 코드블록 → div.mermaid (highlight 전) + docsHighlight(el); + docsRenderMath(el); + docsRunMermaid(el); +} + +function docsExternalLinks(el) { + el.querySelectorAll('a[href]').forEach(a => { + const href = a.getAttribute('href') || ''; + if (/^https?:/i.test(href)) { + a.target = '_blank'; a.rel = 'noopener noreferrer'; + } else if (href && !href.startsWith('#') && docsState.curFile) { + // 상대 링크 → 탐색기 내에서 열기 + const rel = docsResolveRel(docsState.curFile.path, href); + if (rel != null) { + a.addEventListener('click', (ev) => { ev.preventDefault(); docsOpenPath(rel); }); + } + } + }); +} + +function docsResolveRel(basePath, href) { + try { + const clean = href.split('#')[0].split('?')[0]; + if (!clean) return null; + const baseDir = basePath.includes('/') ? basePath.slice(0, basePath.lastIndexOf('/')) : ''; + const parts = (baseDir ? baseDir.split('/') : []).concat(clean.split('/')); + const stack = []; + for (const p of parts) { + if (p === '' || p === '.') continue; + if (p === '..') stack.pop(); + else stack.push(p); + } + return stack.join('/'); + } catch (e) { return null; } +} + +function docsExtractMermaid(el) { + el.querySelectorAll('code.language-mermaid').forEach(code => { + const div = document.createElement('div'); + div.className = 'mermaid'; + div.textContent = code.textContent; + (code.closest('pre') || code).replaceWith(div); + }); +} + +function docsHighlight(el) { + if (typeof hljs === 'undefined') return; + el.querySelectorAll('pre code').forEach(c => { try { hljs.highlightElement(c); } catch (e) {} }); +} + +function docsRenderMath(el) { + if (typeof renderMathInElement === 'undefined') return; + try { + renderMathInElement(el, { + delimiters: [ + { left: '$$', right: '$$', display: true }, + { left: '$', right: '$', display: false }, + { left: '\\[', right: '\\]', display: true }, + { left: '\\(', right: '\\)', display: false }, + ], + throwOnError: false, + }); + } catch (e) {} +} + +async function docsRunMermaid(el) { + const nodes = el.querySelectorAll('.mermaid'); + if (!nodes.length || typeof mermaid === 'undefined') return; + try { await mermaid.run({ nodes }); } catch (e) { /* 실패 시 원문 텍스트 유지 */ } +} + +/* ── 라이브러리 lazy 로더 (md 첫 진입 시 1회) ──────────────── */ +let _docsMdLibs = null; +function docsLoadScript(src) { + return new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = src; s.onload = res; s.onerror = () => rej(new Error('스크립트 로드 실패: ' + src)); + document.head.appendChild(s); + }); +} +function docsLoadCss(href) { + return new Promise((res) => { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = href; l.onload = res; l.onerror = res; + document.head.appendChild(l); + }); +} +function docsEnsureMdLibs() { + if (_docsMdLibs) return _docsMdLibs; + _docsMdLibs = (async () => { + docsLoadCss('/lib/highlight-github.min.css'); + docsLoadCss('/lib/katex/katex.min.css'); + await docsLoadScript('/lib/marked.min.js'); + await docsLoadScript('/lib/purify.min.js'); + await docsLoadScript('/lib/highlight.min.js'); + await docsLoadScript('/lib/katex/katex.min.js'); + await docsLoadScript('/lib/katex/auto-render.min.js'); + await docsLoadScript('/lib/mermaid.min.js'); + try { mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict' }); } catch (e) {} + })(); + return _docsMdLibs; +} + +/* ── 뷰어 툴바 액션 ────────────────────────────────────────── */ +function docsRenderViewerActions() { + const host = document.getElementById('docs-viewer-actions'); + const f = docsState.curFile; + if (!f) { host.innerHTML = ''; return; } + const isText = DOCS_TEXT_EXT.includes(f.ext) || DOCS_MD_EXT.includes(f.ext); + let h = ''; + if (docsState.editing) { + h += ``; + h += ``; + } else { + if (isText && docsState.canManage) h += ``; + h += `⬇ 다운로드`; + } + host.innerHTML = h; +} + +/* ── 인라인 편집 ───────────────────────────────────────────── */ +async function docsEditStart() { + const f = docsState.curFile; + if (!f || !docsState.canManage) return; + let d; + try { d = await docsFetchText(f.path); } catch (e) { alert(e.message); return; } + docsState.editing = true; + docsRenderViewerActions(); + const isMd = DOCS_MD_EXT.includes(f.ext); + const v = docsViewer(); + v.className = 'docs-viewer mode-edit' + (isMd ? ' is-md' : ''); + v.innerHTML = + `
    + + ${isMd ? '
    ' : ''} +
    `; + const ta = document.getElementById('docs-edit-ta'); + ta.value = d.text; + if (isMd) { + await docsEnsureMdLibs(); + const prev = document.getElementById('docs-edit-prev'); + const upd = docsDebounce(() => docsRenderMarkdownInto(prev, ta.value), 250); + ta.addEventListener('input', upd); + docsRenderMarkdownInto(prev, ta.value); + } +} + +async function docsEditSave() { + const f = docsState.curFile; + const ta = document.getElementById('docs-edit-ta'); + if (!f || !ta) return; + try { + const r = await fetch('/api/docs/text', { + method: 'PUT', + headers: docsHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ path: f.path, content: ta.value }), + }); + const d = await r.json(); + if (!d.success) { alert('저장 실패: ' + (d.error || r.status)); return; } + docsState.editing = false; + docsOpenPath(f.path); + docsToast('저장됨'); + } catch (e) { alert('저장 오류: ' + e.message); } +} + +function docsEditCancel() { + docsState.editing = false; + if (docsState.curFile) docsOpenPath(docsState.curFile.path); +} + +/* ── 관리: 새 폴더 / 업로드 / 이름변경 / 삭제 ──────────────── */ +async function docsMkdirPrompt() { + const path = prompt('새 폴더 경로 (루트 기준, 예: docs/notes)'); + if (!path) return; + try { + const r = await fetch('/api/docs/mkdir', { + method: 'POST', headers: docsHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ path }), + }); + const d = await r.json(); + if (!d.success) { alert('실패: ' + (d.error || '')); return; } + await docsRefresh(); + docsToast('폴더 생성됨'); + } catch (e) { alert(e.message); } +} + +function docsUploadPrompt() { + document.getElementById('docs-upload-input').click(); +} + +async function docsUploadDo() { + const input = document.getElementById('docs-upload-input'); + const file = input.files && input.files[0]; + if (!file) return; + const dir = prompt('업로드할 대상 폴더 (루트 기준, 비우면 루트)', '') || ''; + const fd = new FormData(); + fd.append('file', file); + fd.append('path', dir); + try { + const r = await fetch('/api/docs/upload', { method: 'POST', headers: docsHeaders(), body: fd }); + const d = await r.json(); + input.value = ''; + if (!d.success) { alert('업로드 실패: ' + (d.error || '')); return; } + await docsRefresh(); + docsToast('업로드됨: ' + d.path); + } catch (e) { input.value = ''; alert(e.message); } +} + +async function docsRenamePrompt(path) { + if (!docsState.canManage) return; + const next = prompt('새 경로 (루트 기준)', path); + if (!next || next === path) return; + try { + const r = await fetch('/api/docs/rename', { + method: 'POST', headers: docsHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ from: path, to: next }), + }); + const d = await r.json(); + if (!d.success) { alert('실패: ' + (d.error || '')); return; } + await docsRefresh(); + docsToast('이름 변경됨'); + } catch (e) { alert(e.message); } +} + +async function docsDeletePrompt(path, type) { + if (!docsState.canManage) return; + const isDir = type === 'dir'; + const msg = isDir + ? `폴더와 그 안의 모든 내용을 삭제합니다:\n${path}\n\n되돌릴 수 없습니다. 계속할까요?` + : `파일을 삭제합니다:\n${path}\n\n계속할까요?`; + if (!confirm(msg)) return; + try { + const url = '/api/docs?path=' + encodeURIComponent(path) + (isDir ? '&recursive=true' : ''); + const r = await fetch(url, { method: 'DELETE', headers: docsHeaders() }); + const d = await r.json(); + if (!d.success) { alert('삭제 실패: ' + (d.error || '')); return; } + if (docsState.curFile && docsState.curFile.path === path) { + docsState.curFile = null; + document.getElementById('docs-cur-path').textContent = '파일을 선택하세요'; + document.getElementById('docs-viewer-actions').innerHTML = ''; + docsViewer().className = 'docs-viewer'; + docsViewer().innerHTML = '
    ← 왼쪽에서 문서를 선택하세요
    '; + } + await docsRefresh(); + docsToast('삭제됨'); + } catch (e) { alert(e.message); } +} + +/* ── 필터 ──────────────────────────────────────────────────── */ +function docsApplyFilter() { + const q = (document.getElementById('docs-filter').value || '').toLowerCase().trim(); + document.querySelectorAll('#docs-tree .docs-li').forEach(li => { + const row = li.querySelector(':scope > .docs-node'); + if (!row) return; + if (row.dataset.type === 'dir') { li.style.display = ''; return; } + li.style.display = (!q || row.dataset.name.includes(q)) ? '' : 'none'; + }); +} + +/* ── 유틸 ──────────────────────────────────────────────────── */ +function docsFmtSize(n) { + if (n == null) return ''; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} + +function docsFileIcon(ext) { + ext = (ext || '').toLowerCase(); + if (DOCS_MD_EXT.includes(ext)) return '📝'; + if (ext === 'pdf') return '📕'; + if (DOCS_TEXT_EXT.includes(ext)) return '📄'; + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) return '🖼'; + if (['xlsx', 'xls', 'csv'].includes(ext)) return '📊'; + if (['docx', 'doc'].includes(ext)) return '📘'; + if (['json', 'xml', 'yaml', 'yml'].includes(ext)) return '🔧'; + if (['dxf', 'dwg'].includes(ext)) return '📐'; + return '📄'; +} + +function docsDebounce(fn, ms) { + let t; + return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); }; +} + +let _docsToastTimer = null; +function docsToast(msg) { + let el = document.getElementById('docs-toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'docs-toast'; + el.className = 'docs-toast'; + document.body.appendChild(el); + } + el.textContent = msg; + el.classList.add('show'); + clearTimeout(_docsToastTimer); + _docsToastTimer = setTimeout(() => el.classList.remove('show'), 2200); +}