Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions backend/admin/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand All @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions backend/auth/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
20 changes: 17 additions & 3 deletions backend/security_scan/send_suppressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
# 백그라운드 작업 예약
Expand All @@ -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:
Expand Down
159 changes: 159 additions & 0 deletions backend/security_scan/upload_registry.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading