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 = `
+
+ 권한 추가: ${counts.permsAdded}
+ 권한 제거: ${counts.permsRemoved}
+ 호스트 권한 추가: ${counts.hostAdded}
+ 호스트 권한 제거: ${counts.hostRemoved}
+ 선택 권한 추가: ${counts.optAdded}
+ 선택 권한 제거: ${counts.optRemoved}
+ 기타 필드 변경: ${counts.manifestFields}
+ `;
+
+ const codeBody = `
+
+ 파일 추가: ${counts.codeAdded}
+ 파일 제거: ${counts.codeRemoved}
+ 파일 수정: ${counts.codeModified}
+
+ ${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 ``;
+}
+
+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) =>
+ `${x.ext_name} (현재 v${x.latest_version} → v${x.next_version}) `
+ ).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 @@
+
+
+
+ history
+ 버전 변경 사항
+
+
+ difference
+ 자세한 변경 내용 보러가기
+
+
+
+
+
정적 분석
@@ -367,7 +382,7 @@
-
+