diff --git a/backend/admin/log.py b/backend/admin/log.py index 8dafe9c..2b12290 100644 --- a/backend/admin/log.py +++ b/backend/admin/log.py @@ -52,7 +52,14 @@ def load_analysis_logs(data: dict): raise HTTPException(status_code=400, detail="id, browser, version are required") root_path = Path(__file__).resolve().parent.parent.parent - base_search_path = root_path / "analysis_result" / "review" + base_root = root_path / "analysis_result" + + # 결과는 analysis_result/{decision}/{browser}/{name}/{version}/{id}/ 에 저장된다. + # 대시보드 목록의 버킷(source_path)과 실제 저장된 decision 버킷(review/reject/approve)이 + # 다를 수 있으므로(예: 자동 정책 reject) 모든 decision 버킷을 가로질러 탐색한다. + decision_buckets = ( + [d for d in sorted(base_root.iterdir()) if d.is_dir()] if base_root.exists() else [] + ) def find_case_insensitive(parent: Path | None, target_name: str) -> Path | None: if not parent or not parent.exists() or not target_name: @@ -68,27 +75,35 @@ def target_from_source_path() -> Path | None: return None parts = Path(source_path.replace("\\", "/")).parts - if len(parts) < 5 or parts[0] != "review": + if len(parts) < 5: return None + # 선행 버킷명(review/reject/...)은 무시하고 browser/name/version/id tail 만 사용한다. file_stem = Path(parts[-1]).stem - relative_dir = Path(*parts[1:-1], file_stem) - candidate = base_search_path / relative_dir - return candidate if candidate.exists() else None + relative_tail = Path(*parts[1:-1], file_stem) + for bucket in decision_buckets: + candidate = bucket / relative_tail + if candidate.exists(): + return candidate + return None def target_from_params() -> Path | None: - browser_path = find_case_insensitive(base_search_path, browser) - app_path = find_case_insensitive(browser_path, ext_name) - version_path = find_case_insensitive(app_path, version) - target_path = find_case_insensitive(version_path, ext_id) - return target_path + for bucket in decision_buckets: + browser_path = find_case_insensitive(bucket, browser) + app_path = find_case_insensitive(browser_path, ext_name) + version_path = find_case_insensitive(app_path, version) + target_path = find_case_insensitive(version_path, ext_id) + if target_path and target_path.exists(): + return target_path + return None def target_by_id_scan() -> Path | None: - browser_path = find_case_insensitive(base_search_path, browser) - if not browser_path or not browser_path.exists(): - return None - for summary_file in browser_path.glob(f"*/{version}/{ext_id}/summary.json"): - return summary_file.parent + for bucket in decision_buckets: + browser_path = find_case_insensitive(bucket, browser) + if not browser_path or not browser_path.exists(): + continue + for summary_file in browser_path.glob(f"*/{version}/{ext_id}/summary.json"): + return summary_file.parent return None target_path = target_from_source_path() or target_from_params() or target_by_id_scan() @@ -97,13 +112,13 @@ def target_by_id_scan() -> Path | None: raise HTTPException( status_code=404, detail=( - "analysis_result/review/{browser}/{name}/{version}/{id}/summary.json " + "analysis_result/{decision}/{browser}/{name}/{version}/{id}/summary.json " "path was not found" ), ) try: - target_path.resolve().relative_to(base_search_path.resolve()) + target_path.resolve().relative_to(base_root.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Invalid analysis_result path") @@ -131,7 +146,7 @@ def target_by_id_scan() -> Path | None: "success": True, "id": ext_id, "extName": ext_name or target_path.parent.parent.name, - "resolved_path": str(target_path.relative_to(base_search_path)), + "resolved_path": str(target_path.relative_to(base_root)), "data": analysis_results, } except Exception as e: diff --git a/backend/auth/bootstrap.py b/backend/auth/bootstrap.py index 5b601e2..e9ae3e6 100644 --- a/backend/auth/bootstrap.py +++ b/backend/auth/bootstrap.py @@ -118,6 +118,34 @@ def ensure_auth_schema_compatibility() -> None: conn.close() +def ensure_extension_uploads_schema() -> None: + conn = get_db_connection() + if not conn: + raise RuntimeError("Database connection failed while ensuring extension_uploads schema.") + + try: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS extension_uploads ( + ext_id TEXT PRIMARY KEY, + ext_name TEXT NOT NULL, + browser TEXT NOT NULL DEFAULT '', + uploader_id TEXT NOT NULL, + latest_version TEXT NOT NULL DEFAULT '1.0.0', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def ensure_initial_admin() -> None: conn = get_db_connection() if not conn: @@ -250,5 +278,6 @@ def ensure_default_permissions_and_roles() -> None: def initialize_auth_system() -> None: run_migrations() ensure_auth_schema_compatibility() + ensure_extension_uploads_schema() ensure_default_permissions_and_roles() ensure_initial_admin() diff --git a/backend/security_scan/send_suppressor.py b/backend/security_scan/send_suppressor.py index 946e576..5f6b0cf 100644 --- a/backend/security_scan/send_suppressor.py +++ b/backend/security_scan/send_suppressor.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile from backend.auth.security import require_permission +from backend.security_scan.upload_registry import commit_upload router = APIRouter() @@ -17,14 +18,25 @@ async def pending( background_tasks: BackgroundTasks, file: UploadFile = File(...), - plugin_name: str = Form(...), # JS의 'plugin_name'을 받음 - browser: str = Form(...), + plugin_name: str = Form(...), # JS의 'plugin_name'을 받음 (확장 이름 = ext_id) + browser: str = Form(...), version: str = Form(...), + mode: str = Form("first"), # 'first' (첫 업로드) | 'update' (추가 업로드) _user: dict = Depends(require_permission("request_extension")), - # 필수는 아니지만 프론트에서 보낼 수도 있으므로 유연하게 대처하거나 + # 필수는 아니지만 프론트에서 보낼 수도 있으므로 유연하게 대처하거나 # 내부적으로 plugin_name을 활용해 채워줍니다. ): try: + # 계정별 확장 소유/버전 레지스트리에 먼저 확정 기록 (이름 중복/소유권 검증 포함) + commit_upload( + mode=(mode or "first").strip(), + ext_id=plugin_name, + ext_name=plugin_name, + browser=browser, + version=version, + uploader_id=_user["id"], + ) + file_content = await file.read() print("전송 URL;qwqw", URL) # 백그라운드 작업 예약 @@ -43,6 +55,8 @@ async def pending( "status": "processing", "message": "파일 수신 완료. 보안 스캔을 시작합니다." } + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) finally: diff --git a/backend/security_scan/upload_registry.py b/backend/security_scan/upload_registry.py new file mode 100644 index 0000000..b071610 --- /dev/null +++ b/backend/security_scan/upload_registry.py @@ -0,0 +1,159 @@ +from fastapi import APIRouter, Depends, HTTPException +from psycopg2.extras import RealDictCursor +from pydantic import BaseModel + +from backend.auth.security import get_current_user, require_permission +from database import get_db_connection + +router = APIRouter() + + +def bump_patch(version: str) -> str: + """Increment the patch (last) segment of a dotted version. '1.0.1' -> '1.0.2'.""" + parts = str(version or "").strip().split(".") + if not parts or not parts[-1].isdigit(): + return "1.0.0" + parts[-1] = str(int(parts[-1]) + 1) + return ".".join(parts) + + +def _fetch_extension(cur, ext_id: str): + cur.execute( + """ + SELECT ext_id, ext_name, browser, uploader_id, latest_version + FROM extension_uploads + WHERE ext_id = %s + """, + (ext_id,), + ) + return cur.fetchone() + + +def commit_upload(mode: str, ext_id: str, ext_name: str, browser: str, version: str, uploader_id: str) -> None: + """Authoritatively record an upload. Raises HTTPException on name conflict or ownership mismatch.""" + conn = get_db_connection() + if not conn: + raise HTTPException(status_code=500, detail="DB 연결에 실패했습니다.") + try: + with conn.cursor() as cur: + if mode == "first": + cur.execute( + """ + INSERT INTO extension_uploads (ext_id, ext_name, browser, uploader_id, latest_version) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (ext_id) DO NOTHING + """, + (ext_id, ext_name, browser, uploader_id, version), + ) + if cur.rowcount == 0: + conn.rollback() + raise HTTPException(status_code=409, detail="이미 존재하는 확장 이름입니다.") + else: + cur.execute( + """ + UPDATE extension_uploads + SET latest_version = %s, browser = %s, updated_at = now() + WHERE ext_id = %s AND uploader_id = %s + """, + (version, browser, ext_id, uploader_id), + ) + if cur.rowcount == 0: + conn.rollback() + raise HTTPException(status_code=403, detail="본인이 업로드한 확장만 업데이트할 수 있습니다.") + conn.commit() + except HTTPException: + raise + except Exception as e: + conn.rollback() + raise HTTPException(status_code=500, detail=str(e)) + finally: + conn.close() + + +@router.get("/api/uploads/mine") +async def list_my_uploads(_user: dict = Depends(get_current_user)): + conn = get_db_connection() + if not conn: + raise HTTPException(status_code=500, detail="DB 연결에 실패했습니다.") + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT ext_id, ext_name, browser, latest_version + FROM extension_uploads + WHERE uploader_id = %s + ORDER BY updated_at DESC + """, + (_user["id"],), + ) + rows = cur.fetchall() + finally: + conn.close() + + extensions = [ + { + "ext_id": r["ext_id"], + "ext_name": r["ext_name"], + "browser": r["browser"], + "latest_version": r["latest_version"], + "next_version": bump_patch(r["latest_version"]), + } + for r in rows + ] + return {"success": True, "extensions": extensions} + + +class ResolveRequest(BaseModel): + mode: str + ext_id: str | None = None + ext_name: str | None = None + browser: str | None = "" + + +@router.post("/api/uploads/resolve") +async def resolve_upload( + body: ResolveRequest, + _user: dict = Depends(require_permission("upload")), +): + mode = (body.mode or "").strip() + + conn = get_db_connection() + if not conn: + raise HTTPException(status_code=500, detail="DB 연결에 실패했습니다.") + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + if mode == "first": + ext_name = (body.ext_name or "").strip() + if not ext_name: + raise HTTPException(status_code=400, detail="확장 이름을 입력해주세요.") + existing = _fetch_extension(cur, ext_name) + if existing is not None: + raise HTTPException(status_code=409, detail="이미 존재하는 확장 이름입니다.") + return { + "success": True, + "ext_id": ext_name, + "ext_name": ext_name, + "browser": (body.browser or "").strip(), + "version": "1.0.0", + } + + if mode == "update": + ext_id = (body.ext_id or "").strip() + if not ext_id: + raise HTTPException(status_code=400, detail="업데이트할 확장을 선택해주세요.") + existing = _fetch_extension(cur, ext_id) + if existing is None: + raise HTTPException(status_code=404, detail="존재하지 않는 확장입니다.") + if existing["uploader_id"] != _user["id"]: + raise HTTPException(status_code=403, detail="본인이 업로드한 확장만 업데이트할 수 있습니다.") + return { + "success": True, + "ext_id": existing["ext_id"], + "ext_name": existing["ext_name"], + "browser": existing["browser"], + "version": bump_patch(existing["latest_version"]), + } + + raise HTTPException(status_code=400, detail="알 수 없는 업로드 모드입니다.") + finally: + conn.close() diff --git a/frontend/static/js/admin/admin_log.js b/frontend/static/js/admin/admin_log.js index 4c877c6..d7a09b8 100644 --- a/frontend/static/js/admin/admin_log.js +++ b/frontend/static/js/admin/admin_log.js @@ -99,6 +99,7 @@ function renderAnalysisReport(summary) { setText('summary-reason', reason); renderHumanRiskSummary(payload); + renderVersionDiff(payload); renderStaticSection(payload.static_analysis || {}); renderDynamicSection(payload.dynamic_analysis || {}, finalRisk.component_scores?.dynamic || {}); renderObfuscationSection(payload.obfuscation_analysis || {}, finalRisk.component_scores?.obfuscation || {}); @@ -156,6 +157,142 @@ function renderHumanRiskSummary(payload) { setHtml('human-risk-summary', lines.map((line) => `

${escapeHtml(line)}

`).join('')); } +// manifest.json 변경과 그 외 코드 파일 변경을 분리해 집계한다. +function splitVersionDiff(versionDiff) { + const diff = (versionDiff && versionDiff.diff) || {}; + const files = diff.files || {}; + const isManifest = (p) => p === 'manifest.json' || String(p).endsWith('/manifest.json'); + + const filesAdded = files.added || []; + const filesRemoved = files.removed || []; + const filesModified = files.modified || []; + + const manifestFile = filesModified.find((m) => isManifest(m.path)) || null; + const codeAdded = filesAdded.filter((p) => !isManifest(p)); + const codeRemoved = filesRemoved.filter((p) => !isManifest(p)); + const codeModified = filesModified.filter((m) => !isManifest(m.path)); + + const perms = diff.permissions || { added: [], removed: [] }; + const host = diff.host_permissions || { added: [], removed: [] }; + const optional = diff.optional_permissions || { added: [], removed: [] }; + const manifestChanges = diff.manifest_changes || []; + + const permsAdded = (perms.added || []).length; + const permsRemoved = (perms.removed || []).length; + const hostAdded = (host.added || []).length; + const hostRemoved = (host.removed || []).length; + const optAdded = (optional.added || []).length; + const optRemoved = (optional.removed || []).length; + + const manifestChangeCount = + permsAdded + permsRemoved + hostAdded + hostRemoved + optAdded + optRemoved + manifestChanges.length; + const codeChangeCount = codeAdded.length + codeRemoved.length + codeModified.length; + + return { + perms, host, optional, manifestChanges, manifestFile, + codeAdded, codeRemoved, codeModified, + counts: { + permsAdded, permsRemoved, hostAdded, hostRemoved, optAdded, optRemoved, + manifestFields: manifestChanges.length, + manifestChangeCount, + codeAdded: codeAdded.length, + codeRemoved: codeRemoved.length, + codeModified: codeModified.length, + codeChangeCount, + }, + }; +} + +function renderVersionDiff(payload) { + const section = document.getElementById('version-diff-section'); + if (!section) return; + + const versionDiff = payload.version_diff; + if (!versionDiff || !versionDiff.has_previous) { + // 최초 버전이거나 변경 이력이 없으면 박스를 숨긴다. + section.classList.add('hidden'); + return; + } + + const prev = versionDiff.previous_version || '-'; + const curr = versionDiff.current_version || '-'; + const { counts } = splitVersionDiff(versionDiff); + + const subBox = (title, accent, count, label, bodyHtml) => ` +
+
+

${escapeHtml(title)}

+ ${count} ${escapeHtml(label)} +
+ ${bodyHtml} +
`; + + const manifestBody = ` + `; + + const codeBody = ` + + ${counts.codeChangeCount === 0 ? '

manifest.json 외 코드 변경 없음

' : ''}`; + + setHtml('version-diff-summary', ` +

+ v${escapeHtml(prev)} + arrow_forward + v${escapeHtml(curr)} + 버전 간 변경 요약입니다. +

+
+ ${subBox('매니페스트 변경 (manifest.json)', + { border: 'border-purple-200', bg: 'bg-purple-50/60', text: 'text-purple-800', chip: 'bg-purple-100 text-purple-800' }, + counts.manifestChangeCount, '건', manifestBody)} + ${subBox('코드 변경 (manifest.json 외)', + { border: 'border-blue-200', bg: 'bg-blue-50/60', text: 'text-blue-800', chip: 'bg-blue-100 text-blue-800' }, + counts.codeChangeCount, '개 파일', codeBody)} +
+ `); + + section.classList.remove('hidden'); + setupVersionDiffButton(versionDiff); +} + +function setupVersionDiffButton(versionDiff) { + const button = document.getElementById('view-version-diff'); + if (!button) return; + + const params = new URLSearchParams(window.location.search); + const id = params.get('id') || ''; + const version = params.get('version') || ''; + const storageKey = `version_diff:${id}:${version}`; + + try { + sessionStorage.setItem(storageKey, JSON.stringify(versionDiff)); + } catch (e) { + console.warn('version_diff 저장 실패:', e); + } + + button.onclick = () => { + const target = new URLSearchParams({ + id, + name: params.get('name') || '', + browser: params.get('browser') || '', + version, + }); + window.location.href = `/admin/version-diff?${target.toString()}`; + }; +} + function renderStaticSection(staticAnalysis) { const findings = staticAnalysis.key_findings || []; const permissions = staticAnalysis.permissions || []; diff --git a/frontend/static/js/admin/version_diff.js b/frontend/static/js/admin/version_diff.js new file mode 100644 index 0000000..539b803 --- /dev/null +++ b/frontend/static/js/admin/version_diff.js @@ -0,0 +1,307 @@ +document.addEventListener('DOMContentLoaded', () => { + init(); +}); + +async function init() { + const params = new URLSearchParams(window.location.search); + const id = params.get('id') || ''; + const name = params.get('name') || ''; + const browser = params.get('browser') || ''; + const version = params.get('version') || ''; + + setText('head-app_name', name || id || 'N/A'); + + const backHref = `/admin/log?${new URLSearchParams({ id, name, browser, version }).toString()}`; + const backLink = document.getElementById('back-to-log'); + if (backLink) backLink.href = backHref; + const backBtn = document.getElementById('back-btn'); + if (backBtn) backBtn.onclick = () => { window.location.href = backHref; }; + + let versionDiff = readFromSession(id, version); + if (!versionDiff) { + versionDiff = await fetchVersionDiff({ id, name, browser, version }); + } + + if (!versionDiff || !versionDiff.has_previous || !versionDiff.diff) { + showEmpty('표시할 버전 변경 내역이 없습니다. (최초 버전이거나 변경 사항 없음)'); + return; + } + + render(versionDiff); +} + +function readFromSession(id, version) { + try { + const raw = sessionStorage.getItem(`version_diff:${id}:${version}`); + return raw ? JSON.parse(raw) : null; + } catch (e) { + return null; + } +} + +async function fetchVersionDiff({ id, name, browser, version }) { + showLoading(true); + try { + const response = await fetch('/api/admin/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, app_name: name, app_browser: browser, version, source_path: '' }) + }); + const result = await response.json(); + if (!response.ok || !result.success || !result.data) return null; + const summary = result.data.summary || {}; + const payload = summary.web_payload || summary || {}; + return payload.version_diff || null; + } catch (e) { + console.error('version_diff 로드 실패:', e); + return null; + } finally { + showLoading(false); + } +} + +function render(versionDiff) { + const prev = versionDiff.previous_version || '-'; + const curr = versionDiff.current_version || '-'; + setText('head-version-range', `v${prev} → v${curr}`); + + const split = splitVersionDiff(versionDiff); + renderSummary(split.counts, prev, curr); + renderManifestSection(split); + renderCodeSection(split); +} + +// manifest.json 변경과 그 외 코드 파일 변경을 분리한다. (admin_log.js와 동일 규칙) +function splitVersionDiff(versionDiff) { + const diff = (versionDiff && versionDiff.diff) || {}; + const files = diff.files || {}; + const isManifest = (p) => p === 'manifest.json' || String(p).endsWith('/manifest.json'); + + const filesAdded = files.added || []; + const filesRemoved = files.removed || []; + const filesModified = files.modified || []; + + const manifestFile = filesModified.find((m) => isManifest(m.path)) || null; + const codeAdded = filesAdded.filter((p) => !isManifest(p)); + const codeRemoved = filesRemoved.filter((p) => !isManifest(p)); + const codeModified = filesModified.filter((m) => !isManifest(m.path)); + + const perms = diff.permissions || { added: [], removed: [] }; + const host = diff.host_permissions || { added: [], removed: [] }; + const optional = diff.optional_permissions || { added: [], removed: [] }; + const manifestChanges = diff.manifest_changes || []; + + const manifestChangeCount = + (perms.added || []).length + (perms.removed || []).length + + (host.added || []).length + (host.removed || []).length + + (optional.added || []).length + (optional.removed || []).length + + manifestChanges.length; + const codeChangeCount = codeAdded.length + codeRemoved.length + codeModified.length; + + return { + perms, host, optional, manifestChanges, manifestFile, + codeAdded, codeRemoved, codeModified, + counts: { manifestChangeCount, codeChangeCount }, + }; +} + +function renderSummary(counts, prev, curr) { + const chip = (label, count, tone) => ` + + ${escapeHtml(label)} ${count} + `; + const manifestTone = counts.manifestChangeCount > 0 ? 'bg-purple-100 text-purple-800' : 'bg-slate-100 text-slate-500'; + const codeTone = counts.codeChangeCount > 0 ? 'bg-blue-100 text-blue-800' : 'bg-slate-100 text-slate-500'; + + setHtml('diff-summary', ` +

+ v${escapeHtml(prev)} + arrow_forward + v${escapeHtml(curr)} + 버전 간 전체 변경 내역입니다. +

+
+ ${chip('매니페스트 변경', counts.manifestChangeCount, manifestTone)} + ${chip('코드 변경 파일', counts.codeChangeCount, codeTone)} +
+ `); +} + +function permissionBlock(title, group) { + const added = (group && group.added) || []; + const removed = (group && group.removed) || []; + if (!added.length && !removed.length) return ''; + + const pills = (items, tone, sign) => items.map((it) => + `${sign} ${escapeHtml(String(it))}` + ).join(''); + + return ` +
+

${escapeHtml(title)}

+
+ ${pills(added, 'bg-green-100 text-green-800', '+')} + ${pills(removed, 'bg-red-100 text-red-800', '−')} +
+
`; +} + +function renderManifestSection(split) { + const blocks = []; + blocks.push(permissionBlock('권한 (permissions)', split.perms)); + blocks.push(permissionBlock('호스트 권한 (host_permissions)', split.host)); + blocks.push(permissionBlock('선택 권한 (optional_permissions)', split.optional)); + + if (split.manifestChanges.length) { + const rows = split.manifestChanges.map((c) => ` +
+

${escapeHtml(c.field)}

+

- ${escapeHtml(formatValue(c.from))}

+

+ ${escapeHtml(formatValue(c.to))}

+
`).join(''); + blocks.push(` +
+

기타 매니페스트 필드 변경

+ ${rows} +
`); + } + + if (split.manifestFile) { + blocks.push(fileCardForModified(split.manifestFile, 'manifest.json 원본 diff')); + } + + const body = blocks.filter(Boolean); + setHtml('manifest-changes', ` +

+ description + 매니페스트 변경 (manifest.json) +

+ ${body.length + ? `
${body.join('')}
` + : `
매니페스트 변경 사항이 없습니다.
`} + `); +} + +function renderCodeSection(split) { + const parts = []; + + split.codeAdded.forEach((path) => { + parts.push(fileCard(path, '추가됨', 'bg-green-100 text-green-800', ` +
새로 추가된 파일입니다.
`)); + }); + split.codeRemoved.forEach((path) => { + parts.push(fileCard(path, '삭제됨', 'bg-red-100 text-red-800', ` +
삭제된 파일입니다.
`)); + }); + split.codeModified.forEach((entry) => { + parts.push(fileCardForModified(entry)); + }); + + setHtml('code-changes', ` +

+ code + 코드 변경 (manifest.json 외) +

+ ${parts.length + ? `
${parts.join('')}
` + : `
manifest.json 외 코드 변경 사항이 없습니다.
`} + `); +} + +function fileCardForModified(entry, titleOverride) { + let body; + if (entry.is_minified) { + body = `
난독화/압축된 파일이라 인라인 diff를 표시하지 않습니다.
`; + } else if (entry.diff) { + body = renderUnifiedDiff(entry.diff); + if (entry.diff_truncated) { + body += `
diff가 너무 길어 일부만 표시했습니다.
`; + } + } else { + body = `
이전 버전 원본을 찾을 수 없어 변경된 파일 정보만 표시합니다. (sha256: ${escapeHtml((entry.from_sha256 || '').slice(0, 12))} → ${escapeHtml((entry.to_sha256 || '').slice(0, 12))})
`; + } + return fileCard(titleOverride || entry.path, '수정됨', 'bg-blue-100 text-blue-800', body); +} + +function fileCard(path, badge, badgeTone, bodyHtml) { + return ` +
+
+ ${escapeHtml(path)} + ${escapeHtml(badge)} +
+ ${bodyHtml} +
`; +} + +function renderUnifiedDiff(diffText) { + const lines = String(diffText).split('\n'); + let oldLn = 0; + let newLn = 0; + const rows = []; + + for (const line of lines) { + if (line.startsWith('--- ') || line.startsWith('+++ ')) { + continue; + } + if (line.startsWith('@@')) { + const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (m) { + oldLn = parseInt(m[1], 10); + newLn = parseInt(m[2], 10); + } + rows.push(`${escapeHtml(line)}`); + continue; + } + if (line.startsWith('+')) { + rows.push(`${newLn}${escapeHtml(line)}`); + newLn++; + } else if (line.startsWith('-')) { + rows.push(`${oldLn}${escapeHtml(line)}`); + oldLn++; + } else { + rows.push(`${oldLn}${newLn}${escapeHtml(line)}`); + oldLn++; + newLn++; + } + } + + return `
${rows.join('')}
`; +} + +function formatValue(value) { + if (value === null || value === undefined) return '(없음)'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function showEmpty(message) { + const el = document.getElementById('diff-empty'); + if (!el) return; + el.textContent = message; + el.classList.remove('hidden'); +} + +function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value ?? ''; +} + +function setHtml(id, value) { + const el = document.getElementById(id); + if (el) el.innerHTML = value; +} + +function showLoading(isLoading) { + const overlay = document.getElementById('loading-overlay'); + if (overlay) overlay.classList.toggle('hidden', !isLoading); +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/frontend/static/js/upload/build.js b/frontend/static/js/upload/build.js index ba0bbb0..fe31d26 100644 --- a/frontend/static/js/upload/build.js +++ b/frontend/static/js/upload/build.js @@ -1,4 +1,6 @@ let uploadFile = null +let uploadMode = 'first' // 'first' | 'update' +let myExtensions = [] // 추가 업로드 시 불러온 내 확장 목록 document.querySelector('#selectBuildFile').addEventListener('click', () => { document.querySelector('#UploadFile').click(); @@ -16,6 +18,88 @@ document.querySelector('#UploadFile').addEventListener('change', async (e) => { +// ---- 업로드 모드 (첫 업로드 / 추가 업로드) ---- +const modeFirstBtn = document.getElementById('mode-first'); +const modeUpdateBtn = document.getElementById('mode-update'); +const modeHint = document.getElementById('mode-hint'); +const updateSelectWrap = document.getElementById('update-select-wrap'); +const extSelect = document.getElementById('extSelect'); +const extNameInput = document.getElementById('extName'); +const versionInput = document.getElementById('version'); +const browserSelect = document.getElementById('browser'); +const extIDInput = document.getElementById('extID'); + +const ACTIVE_TAB = 'px-5 py-2 rounded-lg text-sm font-semibold transition-colors bg-primary text-on-primary'; +const INACTIVE_TAB = 'px-5 py-2 rounded-lg text-sm font-semibold transition-colors text-on-surface-variant hover:bg-surface-container'; + +function setExtNameEditable(editable) { + extNameInput.readOnly = !editable; + extNameInput.classList.toggle('bg-surface-container', !editable); + extNameInput.classList.toggle('text-on-surface-variant', !editable); + extNameInput.classList.toggle('cursor-not-allowed', !editable); + extNameInput.classList.toggle('bg-surface-container-low', editable); +} + +async function applyMode(mode) { + uploadMode = mode; + modeFirstBtn.className = mode === 'first' ? ACTIVE_TAB : INACTIVE_TAB; + modeUpdateBtn.className = mode === 'update' ? ACTIVE_TAB : INACTIVE_TAB; + + if (mode === 'first') { + updateSelectWrap.classList.add('hidden'); + modeHint.textContent = '새로운 확장을 처음 업로드합니다. 버전은 자동으로 1.0.0으로 설정됩니다.'; + setExtNameEditable(true); + extNameInput.value = ''; + versionInput.value = '1.0.0'; + browserSelect.disabled = false; + } else { + updateSelectWrap.classList.remove('hidden'); + modeHint.textContent = '이미 업로드한 확장을 선택하면 버전이 자동으로 1단계 올라갑니다.'; + setExtNameEditable(false); + browserSelect.disabled = true; + await loadMyExtensions(); + } +} + +async function loadMyExtensions() { + extSelect.innerHTML = ''; + try { + const res = await fetch('/api/uploads/mine'); + const data = await res.json(); + myExtensions = (data && data.extensions) || []; + } catch (e) { + myExtensions = []; + } + + if (!myExtensions.length) { + extSelect.innerHTML = ''; + extNameInput.value = ''; + versionInput.value = ''; + return; + } + + extSelect.innerHTML = '' + + myExtensions.map((x) => + `` + ).join(''); +} + +extSelect.addEventListener('change', () => { + const ext = myExtensions.find((x) => x.ext_id === extSelect.value); + if (!ext) { + extNameInput.value = ''; + versionInput.value = ''; + return; + } + extNameInput.value = ext.ext_name; + versionInput.value = ext.next_version; + if (ext.browser) browserSelect.value = ext.browser; +}); + +modeFirstBtn.addEventListener('click', () => applyMode('first')); +modeUpdateBtn.addEventListener('click', () => applyMode('update')); +applyMode('first'); + document.getElementById('buildBtn').addEventListener('click', async () => { // 1. 파일 선택 여부 확인 if (!uploadFile) { @@ -39,18 +123,38 @@ document.getElementById('buildBtn').addEventListener('click', async () => { if (isConfirmed) { try { + // 모드/이름/버전을 서버에서 확정받는다 (이름 중복·소유권 검증 포함) + const resolveBody = uploadMode === 'update' + ? { mode: 'update', ext_id: extSelect.value } + : { mode: 'first', ext_name: extNameInput.value.trim(), browser: browserSelect.value }; + + const resolveRes = await fetch('/api/uploads/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(resolveBody) + }); + if (!resolveRes.ok) { + const msg = await readErrorMessage(resolveRes, '업로드 정보를 확인하지 못했습니다.'); + throw new Error(msg); + } + const resolved = await resolveRes.json(); + + const version = resolved.version; + const browser = browserSelect.value; + const extName = resolved.ext_name; + const extID = resolved.ext_id; - // 이거 나중에 입력받도록 바꾸기. - const version = document.getElementById("version").value - const browser = document.getElementById("browser").value - const extID = document.getElementById("extID").value - const extName = document.getElementById("extName").value + // 화면에도 확정된 값 반영 + versionInput.value = version; + extNameInput.value = extName; + extIDInput.value = extID; const formData = new FormData(); // 중요: pending 함수가 Form(...)으로 요구하는 변수명과 일치시켜야 함 formData.append('plugin_name', extName); formData.append('browser', browser); formData.append('version', version); + formData.append('mode', uploadMode); formData.append("extID", extID) formData.append('file', uploadFile); diff --git a/frontend/templates/admin/log.html b/frontend/templates/admin/log.html index c02acdc..1dc10e1 100644 --- a/frontend/templates/admin/log.html +++ b/frontend/templates/admin/log.html @@ -322,6 +322,21 @@

+ +

정적 분석

@@ -367,7 +382,7 @@

- + diff --git a/frontend/templates/admin/version_diff.html b/frontend/templates/admin/version_diff.html new file mode 100644 index 0000000..604466f --- /dev/null +++ b/frontend/templates/admin/version_diff.html @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+
+ +
+
+ +

버전 변경 내역

+
+N/A + +
+
+ +
+ + +
+ + +
+ + +
+ + + +
+
+ + + + diff --git a/frontend/templates/upload/build.html b/frontend/templates/upload/build.html index 274c501..20e7777 100644 --- a/frontend/templates/upload/build.html +++ b/frontend/templates/upload/build.html @@ -236,24 +236,45 @@

확장 프로그램 정보 (Extension Info)

- -
-
- - + + +
+ +
+ +
+

새로운 확장을 처음 업로드합니다. 버전은 자동으로 1.0.0으로 설정됩니다.

+
+ + + +
- - 확장 프로그램 이름 +
- - + +
@@ -269,6 +290,7 @@

확장 프로그램 정보 (Extension Info)

+
@@ -278,6 +300,6 @@

확장 프로그램 정보 (Extension Info)

- + \ No newline at end of file diff --git a/main.py b/main.py index 016642f..acbc207 100644 --- a/main.py +++ b/main.py @@ -216,6 +216,12 @@ async def admin_log(request: Request): if blocked is not None: return blocked return templates.TemplateResponse(request, "admin/log.html", {"request": request}) +@app.get("/admin/version-diff", response_class=HTMLResponse) +async def admin_version_diff(request: Request): + blocked = require_admin_page(request) + if blocked is not None: + return blocked + return templates.TemplateResponse(request, "admin/version_diff.html", {"request": request}) @app.get("/admin/policy", response_class=HTMLResponse) async def admin_policy(request: Request): blocked = require_admin_page(request) @@ -296,6 +302,10 @@ async def build(request: Request): from backend.security_scan.send_suppressor import router as send_suppressor_router app.include_router(send_suppressor_router) +# 계정별 확장 업로드 레지스트리 (첫/추가 업로드 + 자동 버전) +from backend.security_scan.upload_registry import router as upload_registry_router +app.include_router(upload_registry_router) + # nexus 리스트 조회 from backend.nexus.nexus_repo import router as nexus_repo app.include_router(nexus_repo)