대시보드 삭제 버튼은 지금까지 projects.deleted_at만 찍는 소프트 삭제였다. 배포에서는
그게 맞다 — 사용자가 올린 라이다 원본은 다른 프로젝트에 재활용할 자산이다. 그러나 개발
중에는 프로젝트를 반복 생성·삭제하는데 정리 잡이 없어 수십 GB 원본이 계속 쌓인다.
config_system.py 맨 위에 PROJECT_DELETE_HARD_ENABLED를 두고 갈랐다. 기본값 False라
환경변수를 빠뜨린 배포 환경은 자동으로 안전한 쪽에 선다. True면 projects 행을 실제로
DELETE 하고(자식 테이블은 FK CASCADE로 함께 사라진다) storage/{회사}/{사용자}/{프로젝트ID}/
폴더를 통째로 지운다.
자식 테이블 목록은 코드에 나열하지 않았다. projects.id 참조가 전부 ON DELETE CASCADE라
행 하나면 충분하고, 목록을 복사해 두면 스키마가 바뀔 때 조용히 어긋난다.
순서는 DB 먼저 커밋, rmtree 나중이다. 파일을 먼저 지우면 DB 실패 시 실체 없는 프로젝트가
목록에 남아 화면이 깨진다. 반대면 rmtree가 실패해도 고아 폴더만 남고 정합성은 유지된다.
resolve_stored_project_path()는 끝에서 makedirs를 하므로 삭제에 쓸 수 없다 — 지우기 직전에
폴더를 되살린다. 검증만 하는 resolve_project_root_for_delete()를 따로 뒀고, 저장소 루트 안 ·
세그먼트 정확히 4개 · 마지막 세그먼트가 요청 project_id와 일치를 모두 요구한다. DB의
storage_path가 오염돼도 상위 폴더나 남의 폴더를 지우지 못한다.
하드 삭제 모드에서는 확인 모달 문구를 바꿔 원본까지 사라진다고 알린다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
117 lines
5.7 KiB
Python
117 lines
5.7 KiB
Python
"""프로젝트 영구저장소 경로 유틸리티."""
|
|
|
|
import os
|
|
from pathlib import PurePosixPath
|
|
|
|
from config.config_system import (
|
|
PROJECT_STORAGE_STAGE_DIRS,
|
|
STORAGE_BASE_DIR,
|
|
TEMP_UPLOAD_DIR_NAME,
|
|
)
|
|
|
|
PROJECT_STORAGE_LAYOUT_V2 = (
|
|
("B03_FileInput", "input"),
|
|
("B04_PreProcess", "processed"),
|
|
("B04_PreProcess", "models"),
|
|
("B05_Profile", "route"),
|
|
("B06_Section", "longitudinal"),
|
|
("B06_Section", "cross_sections"),
|
|
("B08_DesignDetail", "structures"),
|
|
("B07_Quantity", "quantities"),
|
|
("B09_Estimation", "v1"),
|
|
)
|
|
|
|
|
|
def get_project_stage_path(project_root: str, stage: str) -> str:
|
|
"""프로젝트 루트 아래의 허용된 워크플로우 단계 폴더를 생성한다."""
|
|
if stage not in PROJECT_STORAGE_STAGE_DIRS:
|
|
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
|
|
|
|
root = os.path.abspath(project_root)
|
|
path = os.path.abspath(os.path.join(root, stage))
|
|
if os.path.commonpath((root, path)) != root:
|
|
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
|
|
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def ensure_project_storage_layout(project_root: str) -> None:
|
|
"""신규 워크플로우 저장소 하위 폴더를 누락분만 생성한다."""
|
|
root = os.path.abspath(project_root)
|
|
for stage, subdir in PROJECT_STORAGE_LAYOUT_V2:
|
|
if stage not in PROJECT_STORAGE_STAGE_DIRS:
|
|
raise ValueError(f"허용되지 않은 프로젝트 저장 단계입니다: {stage}")
|
|
path = os.path.abspath(os.path.join(root, stage, subdir))
|
|
if os.path.commonpath((root, path)) != root:
|
|
raise ValueError("단계 저장 경로가 프로젝트 루트를 벗어났습니다.")
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
def resolve_temp_batch_path(user_id: int, batch_id: str, *, create: bool = True) -> str:
|
|
"""임시 보관함 묶음 폴더(`storage/tmp/{user_id}/{batch_id}`)를 돌려준다.
|
|
|
|
내부 구조는 프로젝트 저장소와 똑같이 `B03_FileInput/input/...`을 쓴다 — 그래야 청크
|
|
저장·병합 엔진(`resolve_upload_destination`, `resolve_chunk_session_dir`)을 그대로
|
|
재사용할 수 있고, 나중에 프로젝트로 옮길 때도 같은 상대 경로로 이어 붙기만 하면 된다.
|
|
"""
|
|
if not str(user_id).isdigit():
|
|
raise ValueError("임시 보관함 사용자 식별자가 올바르지 않습니다.")
|
|
if not batch_id or any(sep in batch_id for sep in ("/", "\\", "..")):
|
|
raise ValueError("임시 보관함 묶음 식별자가 올바르지 않습니다.")
|
|
|
|
temp_root = os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
|
|
path = os.path.abspath(os.path.join(temp_root, str(user_id), batch_id))
|
|
if os.path.commonpath((temp_root, path)) != temp_root or path == temp_root:
|
|
raise ValueError("임시 보관함 경로가 보관함 루트를 벗어났습니다.")
|
|
if create:
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def temp_upload_root() -> str:
|
|
"""임시 보관함 루트(`storage/tmp`) 절대 경로."""
|
|
return os.path.abspath(os.path.join(STORAGE_BASE_DIR, TEMP_UPLOAD_DIR_NAME))
|
|
|
|
|
|
def resolve_project_root_for_delete(relative_path: str, project_id: str) -> str:
|
|
"""삭제용 프로젝트 루트를 해석한다 — 폴더를 만들지 않는다.
|
|
|
|
`resolve_stored_project_path()`는 끝에서 `makedirs`와 레이아웃 생성을 한다. 지우기
|
|
직전에 그걸 쓰면 폴더를 되살려 놓고 지우는 꼴이 된다. 그래서 검증만 하는 짝을 둔다.
|
|
|
|
DB의 `storage_path`가 오염돼도 남의 폴더를 지우지 못하게 네 겹으로 막는다:
|
|
저장소 루트 안, 세그먼트 정확히 4개, 마지막 세그먼트가 요청받은 프로젝트 ID와 일치.
|
|
"""
|
|
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
|
if normalized.is_absolute() or ".." in normalized.parts:
|
|
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
|
# storage/{회사}/{사용자}/{프로젝트ID} — 상위 폴더를 통째로 지우는 사고를 막는다.
|
|
if len(normalized.parts) != 4 or normalized.parts[0] != "storage":
|
|
raise ValueError("삭제 대상 경로는 storage/회사/사용자/프로젝트ID 형태여야 합니다.")
|
|
if normalized.parts[3] != str(project_id):
|
|
raise ValueError("저장 경로의 프로젝트 ID가 삭제 요청과 일치하지 않습니다.")
|
|
|
|
storage_root = os.path.abspath(STORAGE_BASE_DIR)
|
|
path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:]))
|
|
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
|
|
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
|
|
return path
|
|
|
|
|
|
def resolve_stored_project_path(relative_path: str) -> str:
|
|
"""DB의 storage 기준 상대 경로를 검증해 실제 프로젝트 경로로 변환한다."""
|
|
normalized = PurePosixPath(relative_path.replace("\\", "/"))
|
|
if normalized.is_absolute() or ".." in normalized.parts:
|
|
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
|
|
if not normalized.parts or normalized.parts[0] != "storage":
|
|
raise ValueError("프로젝트 저장 경로는 storage/로 시작해야 합니다.")
|
|
|
|
storage_root = os.path.abspath(STORAGE_BASE_DIR)
|
|
path = os.path.abspath(os.path.join(storage_root, *normalized.parts[1:]))
|
|
if os.path.commonpath((storage_root, path)) != storage_root or path == storage_root:
|
|
raise ValueError("프로젝트 저장 경로가 저장소 루트를 벗어났습니다.")
|
|
os.makedirs(path, exist_ok=True)
|
|
ensure_project_storage_layout(path)
|
|
return path
|