"""카페24 디자인 보관함 FTP — 스킨에 올라간 개별 파일(예: product-swiper.js) 읽기/쓰기. Admin API(OAuth)로는 스킨 파일을 다루지 못한다(`config.py` 상단 설명 참고). 카페24가 스킨 파일에 제공하는 유일한 프로그램적 접근은 "디자인 보관함" FTP 계정이며, Admin API 와는 별개의 인증(호스트/포트/아이디/비밀번호)이다. 그래서 여기는 `Cafe24Client` (httpx 기반)를 쓰지 않고 표준 라이브러리 `ftplib` 를 직접 쓴다. 실물 확인(스크린샷): 호스트 `{mall_id}.ftp.cafe24.com`, 포트 21, SSL/TLS 미사용 (평문 FTP). 파일은 텍스트(UTF-8)로 다룬다. """ from __future__ import annotations import ftplib import io from .config import Cafe24FtpConfig from .errors import Cafe24FtpError # 응답이 없을 때 무한 대기하지 않도록 — 관리 화면 요청 안에서 동기 호출되므로 # 너무 길면 안 되지만, 파일 하나(수십 KB) 전송에는 충분히 넉넉해야 한다. _TIMEOUT_SECONDS = 20 def _connect(config: Cafe24FtpConfig) -> ftplib.FTP: try: ftp = ftplib.FTP() ftp.connect(config.host, config.port, timeout=_TIMEOUT_SECONDS) ftp.login(config.user, config.password) ftp.set_pasv(True) return ftp except ftplib.all_errors as exc: # OSError 포함 — 연결 실패도 여기서 잡힌다 # 비밀번호가 예외 메시지에 섞여 나오지 않는다 — ftplib 오류는 서버 응답 # 문자열이며 우리가 보낸 자격증명을 되풀이하지 않는다. raise Cafe24FtpError(f"FTP 연결/로그인에 실패했습니다: {exc}") from exc def read_text_file(config: Cafe24FtpConfig, path: str) -> str: """디자인 보관함의 파일 1개를 읽어 텍스트로 돌려준다.""" ftp = _connect(config) try: buf = io.BytesIO() try: ftp.retrbinary(f"RETR {path}", buf.write) except ftplib.all_errors as exc: raise Cafe24FtpError(f"파일을 읽지 못했습니다({path}): {exc}") from exc try: return buf.getvalue().decode("utf-8") except UnicodeDecodeError as exc: raise Cafe24FtpError(f"파일 인코딩을 UTF-8로 해석하지 못했습니다({path}): {exc}") from exc finally: try: ftp.quit() except Exception: # noqa: BLE001 — 정리 실패는 무시 ftp.close() def write_text_file(config: Cafe24FtpConfig, path: str, content: str) -> None: """디자인 보관함의 파일 1개를 통째로 덮어쓴다.""" ftp = _connect(config) try: buf = io.BytesIO(content.encode("utf-8")) try: ftp.storbinary(f"STOR {path}", buf) except ftplib.all_errors as exc: raise Cafe24FtpError(f"파일을 쓰지 못했습니다({path}): {exc}") from exc finally: try: ftp.quit() except Exception: # noqa: BLE001 — 정리 실패는 무시 ftp.close()